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;
|
||||
};
|
||||
|
||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const { data, status } = useSession();
|
||||
const to = usePathname();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
@@ -84,6 +84,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
markSessionExpired("refresh_failed");
|
||||
return;
|
||||
}
|
||||
if (status === "unauthenticated") {
|
||||
markSessionExpired("unauthorized");
|
||||
return;
|
||||
}
|
||||
if (status === "authenticated") {
|
||||
clearSessionExpired();
|
||||
}
|
||||
@@ -99,7 +103,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
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<AppProps>) => {
|
||||
}}
|
||||
>
|
||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||
<RoutePermissionGuard authenticated={status === "authenticated"}>
|
||||
{props.children}
|
||||
</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("RoutePermissionGuard", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<RoutePermissionGuard>
|
||||
<RoutePermissionGuard authenticated>
|
||||
<div>受保护内容</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
@@ -43,7 +43,7 @@ describe("RoutePermissionGuard", () => {
|
||||
|
||||
it("shows the permission error when the session is still valid", () => {
|
||||
render(
|
||||
<RoutePermissionGuard>
|
||||
<RoutePermissionGuard authenticated>
|
||||
<div>受保护内容</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
@@ -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(
|
||||
<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 = ({
|
||||
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 (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="warning">
|
||||
|
||||
Reference in New Issue
Block a user