Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff59c5a7e | ||
|
|
d98fa359ae | ||
|
|
a191d81c29 |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -1,4 +1,5 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
@@ -25,6 +26,8 @@ let mockSessionState: {
|
||||
};
|
||||
let mockPathname = "/network-simulation";
|
||||
let mockProjectProviderBlocks = false;
|
||||
let capturedLogin: (() => Promise<unknown>) | null = null;
|
||||
const replaceMock = jest.fn();
|
||||
|
||||
jest.mock("next-auth/react", () => ({
|
||||
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
||||
@@ -34,10 +37,20 @@ jest.mock("next-auth/react", () => ({
|
||||
|
||||
jest.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathname,
|
||||
useRouter: () => ({ replace: replaceMock }),
|
||||
}));
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
Refine: ({ children }: { children: ReactNode }) => children,
|
||||
Refine: ({
|
||||
children,
|
||||
authProvider,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
authProvider: { login: (params: Record<string, never>) => Promise<unknown> };
|
||||
}) => {
|
||||
capturedLogin = () => authProvider.login({});
|
||||
return children;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@refinedev/kbar", () => ({
|
||||
@@ -78,6 +91,9 @@ describe("RefineContext access authentication", () => {
|
||||
clearIntentionalLogout();
|
||||
mockPathname = "/network-simulation";
|
||||
mockProjectProviderBlocks = false;
|
||||
capturedLogin = null;
|
||||
replaceMock.mockReset();
|
||||
jest.mocked(signIn).mockClear();
|
||||
mockSessionState = {
|
||||
data: {
|
||||
accessToken: "expired-access-token",
|
||||
@@ -217,6 +233,25 @@ describe("RefineContext access authentication", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts login with a safe post-authentication callback from the login route", async () => {
|
||||
mockPathname = "/login";
|
||||
mockSessionState = { data: null, status: "unauthenticated" };
|
||||
|
||||
render(
|
||||
<App>
|
||||
<div>登录页面</div>
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(capturedLogin).not.toBeNull();
|
||||
await capturedLogin!();
|
||||
|
||||
expect(signIn).toHaveBeenCalledWith("keycloak", {
|
||||
callbackUrl: "/",
|
||||
redirect: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat returning to a protected route after logout as session expiry", async () => {
|
||||
markIntentionalLogout();
|
||||
mockSessionState = { data: null, status: "unauthenticated" };
|
||||
|
||||
@@ -214,7 +214,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const authProvider: AuthProvider = {
|
||||
login: async () => {
|
||||
signIn("keycloak", {
|
||||
callbackUrl: to ? to.toString() : "/",
|
||||
callbackUrl: to && to !== "/login" ? to.toString() : "/",
|
||||
redirect: true,
|
||||
});
|
||||
return { success: true };
|
||||
|
||||
@@ -4,8 +4,12 @@ import { useAccessStore } from "@/store/accessStore";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { RoutePermissionGuard } from "./RoutePermissionGuard";
|
||||
|
||||
const replaceMock = jest.fn();
|
||||
let mockPathname = "/network-simulation";
|
||||
|
||||
jest.mock("next/navigation", () => ({
|
||||
usePathname: () => "/network-simulation",
|
||||
usePathname: () => mockPathname,
|
||||
useRouter: () => ({ replace: replaceMock }),
|
||||
}));
|
||||
|
||||
jest.mock("@/components/header", () => ({
|
||||
@@ -16,6 +20,8 @@ jest.mock("@/components/header", () => ({
|
||||
|
||||
describe("RoutePermissionGuard", () => {
|
||||
beforeEach(() => {
|
||||
replaceMock.mockReset();
|
||||
mockPathname = "/network-simulation";
|
||||
useAccessStore.setState({
|
||||
context: null,
|
||||
permissions: [],
|
||||
@@ -86,7 +92,7 @@ describe("RoutePermissionGuard", () => {
|
||||
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
|
||||
});
|
||||
|
||||
it("checks authentication before the expired state effect runs", () => {
|
||||
it("redirects an unauthenticated protected route to login", () => {
|
||||
render(
|
||||
<RoutePermissionGuard authenticated={false}>
|
||||
<div>受保护内容</div>
|
||||
@@ -95,5 +101,20 @@ describe("RoutePermissionGuard", () => {
|
||||
|
||||
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||
expect(replaceMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("allows the login route to render while unauthenticated", () => {
|
||||
mockPathname = "/login";
|
||||
|
||||
render(
|
||||
<RoutePermissionGuard authenticated={false}>
|
||||
<div>登录页面</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("登录页面")).toBeInTheDocument();
|
||||
expect(screen.queryByText("登录状态已失效")).not.toBeInTheDocument();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
import { Header } from "@/components/header";
|
||||
import { permissionForPath } from "@/lib/permissions";
|
||||
@@ -26,10 +26,22 @@ export const RoutePermissionGuard = ({
|
||||
accessDeniedReason?: "account" | "project" | null;
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
const loading = useAccessStore((state) => state.loading);
|
||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||
const requiredPermission = permissionForPath(pathname);
|
||||
const isLoginRoute = pathname === "/login";
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated && !isLoginRoute) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [authenticated, isLoginRoute, router]);
|
||||
|
||||
if (isLoginRoute) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!authenticated || sessionExpired) {
|
||||
return (
|
||||
|
||||
@@ -136,13 +136,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
padding: "6px 12px",
|
||||
marginLeft: "8px",
|
||||
transition:
|
||||
"background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease",
|
||||
"background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease",
|
||||
border: "1px solid transparent",
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.05)"
|
||||
: "rgba(0, 0, 0, 0.04)",
|
||||
transform: "translateY(-1px)",
|
||||
border: `1px solid ${mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.2)"
|
||||
: "rgba(0, 0, 0, 0.1)"
|
||||
@@ -153,6 +154,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
"&:active": {
|
||||
transform: "translateY(0px)",
|
||||
boxShadow: "none",
|
||||
},
|
||||
}}
|
||||
@@ -187,7 +189,9 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(0,0,0,0.1)"
|
||||
}`,
|
||||
transition: "transform 0.3s ease",
|
||||
".MuiButtonBase-root:hover &": {
|
||||
transform: "rotate(5deg)",
|
||||
borderColor: "primary.main",
|
||||
},
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user