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>
|
||||
|
||||
Reference in New Issue
Block a user