fix(auth): handle expired session sources before permissions
The previous fix only prioritized authStore inside the route guard, so an unauthenticated NextAuth session or a suppressed access-context 401 still collapsed into an empty permission set. Propagate both authentication signals before authorization so expired sessions consistently reauthenticate.
This commit is contained in:
@@ -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(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,7 +56,7 @@ type AppProps = {
|
|||||||
defaultMode?: string;
|
defaultMode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||||
const { data, status } = useSession();
|
const { data, status } = useSession();
|
||||||
const to = usePathname();
|
const to = usePathname();
|
||||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||||
@@ -84,6 +84,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
markSessionExpired("refresh_failed");
|
markSessionExpired("refresh_failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
markSessionExpired("unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
clearSessionExpired();
|
clearSessionExpired();
|
||||||
}
|
}
|
||||||
@@ -99,7 +103,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
setAccessLoading(true);
|
setAccessLoading(true);
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||||
skipAuthRedirect: true,
|
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -368,7 +371,9 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
<RoutePermissionGuard authenticated={status === "authenticated"}>
|
||||||
|
{props.children}
|
||||||
|
</RoutePermissionGuard>
|
||||||
<RefineKbar />
|
<RefineKbar />
|
||||||
</Refine>
|
</Refine>
|
||||||
</RefineSnackbarProvider>
|
</RefineSnackbarProvider>
|
||||||
|
|||||||
@@ -657,7 +657,6 @@ export const SystemAdminPanel = () => {
|
|||||||
try {
|
try {
|
||||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
||||||
projectHeaderMode: "omit",
|
projectHeaderMode: "omit",
|
||||||
skipAuthRedirect: true,
|
|
||||||
});
|
});
|
||||||
if (!adminResponse.ok) {
|
if (!adminResponse.ok) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe("RoutePermissionGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<RoutePermissionGuard>
|
<RoutePermissionGuard authenticated>
|
||||||
<div>受保护内容</div>
|
<div>受保护内容</div>
|
||||||
</RoutePermissionGuard>,
|
</RoutePermissionGuard>,
|
||||||
);
|
);
|
||||||
@@ -43,7 +43,7 @@ describe("RoutePermissionGuard", () => {
|
|||||||
|
|
||||||
it("shows the permission error when the session is still valid", () => {
|
it("shows the permission error when the session is still valid", () => {
|
||||||
render(
|
render(
|
||||||
<RoutePermissionGuard>
|
<RoutePermissionGuard authenticated>
|
||||||
<div>受保护内容</div>
|
<div>受保护内容</div>
|
||||||
</RoutePermissionGuard>,
|
</RoutePermissionGuard>,
|
||||||
);
|
);
|
||||||
@@ -51,4 +51,15 @@ describe("RoutePermissionGuard", () => {
|
|||||||
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
|
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("checks authentication before the expired state effect runs", () => {
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard authenticated={false}>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { useAuthStore } from "@/store/authStore";
|
|||||||
|
|
||||||
export const RoutePermissionGuard = ({
|
export const RoutePermissionGuard = ({
|
||||||
children,
|
children,
|
||||||
|
authenticated,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
authenticated: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
@@ -20,7 +22,7 @@ export const RoutePermissionGuard = ({
|
|||||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||||
const requiredPermission = permissionForPath(pathname);
|
const requiredPermission = permissionForPath(pathname);
|
||||||
|
|
||||||
if (sessionExpired) {
|
if (!authenticated || sessionExpired) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3 }}>
|
<Box sx={{ p: 3 }}>
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">
|
||||||
|
|||||||
Reference in New Issue
Block a user