Commit 88bba4514a5e37865e80c5ec9620860cd4084318
1 parent
fd4d42ed
feat(fe-shell): 受保护路由守卫 RequireAuth 三态 REQ-USR-004
Showing
2 changed files
with
92 additions
and
0 deletions
frontend/src/router/RequireAuth.tsx
0 → 100644
| 1 | +// REQ-USR-004: 受保护路由守卫(BR1)。三态:unauthenticated / authResolving / ready。 | ||
| 2 | +import { Navigate, Outlet, useLocation } from 'react-router-dom'; | ||
| 3 | +import { Spin } from 'antd'; | ||
| 4 | +import { useAppSelector } from '../store/hooks'; | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * 布局守卫: | ||
| 8 | + * - 无 token → 重定向 /login,携带 state.from(来源路径,登录后可回跳,BR1) | ||
| 9 | + * - 有 token 但 user 未就绪 → 渲染 Spin 占位(authResolving,token 已存在但用户信息尚未拉取) | ||
| 10 | + * - token + user 均就绪 → 放行(ready),渲染 <Outlet/> | ||
| 11 | + */ | ||
| 12 | +export default function RequireAuth() { | ||
| 13 | + const { token, user } = useAppSelector((s) => s.auth); | ||
| 14 | + const location = useLocation(); | ||
| 15 | + | ||
| 16 | + if (!token) { | ||
| 17 | + return <Navigate to="/login" replace state={{ from: location.pathname }} />; | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + if (!user) { | ||
| 21 | + return ( | ||
| 22 | + <div | ||
| 23 | + data-testid="auth-resolving" | ||
| 24 | + style={{ | ||
| 25 | + height: '100vh', | ||
| 26 | + display: 'flex', | ||
| 27 | + alignItems: 'center', | ||
| 28 | + justifyContent: 'center', | ||
| 29 | + }} | ||
| 30 | + > | ||
| 31 | + <Spin size="large" tip="加载中…"> | ||
| 32 | + <div style={{ padding: 24 }} /> | ||
| 33 | + </Spin> | ||
| 34 | + </div> | ||
| 35 | + ); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + return <Outlet />; | ||
| 39 | +} |
frontend/tests/unit/RequireAuth.test.tsx
0 → 100644
| 1 | +// REQ-USR-004: RequireAuth 守卫三态(BR1) — authResolving / unauthenticated / ready | ||
| 2 | +import { describe, it, expect } from 'vitest'; | ||
| 3 | +import { screen } from '@testing-library/react'; | ||
| 4 | +import { Routes, Route, Outlet, useLocation } from 'react-router-dom'; | ||
| 5 | +import { renderShell } from './renderShell'; | ||
| 6 | +import RequireAuth from '../../src/router/RequireAuth'; | ||
| 7 | + | ||
| 8 | +// 哨兵:登录页读出 state.from,便于断言重定向携带来源 | ||
| 9 | +function LoginSentinel() { | ||
| 10 | + const loc = useLocation(); | ||
| 11 | + const from = (loc.state as { from?: string } | null)?.from; | ||
| 12 | + return <div data-testid="login-sentinel">login from={from ?? 'none'}</div>; | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +function ProtectedSentinel() { | ||
| 16 | + return <div data-testid="protected-sentinel">protected-content</div>; | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +function renderGuard(initialEntries: string[], preloadedAuth?: Parameters<typeof renderShell>[1]['preloadedAuth']) { | ||
| 20 | + return renderShell( | ||
| 21 | + <Routes> | ||
| 22 | + <Route path="/login" element={<LoginSentinel />} /> | ||
| 23 | + <Route element={<RequireAuth />}> | ||
| 24 | + <Route path="/" element={<ProtectedSentinel />} /> | ||
| 25 | + </Route> | ||
| 26 | + </Routes>, | ||
| 27 | + { initialEntries, preloadedAuth }, | ||
| 28 | + ); | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +describe('RequireAuth', () => { | ||
| 32 | + it('redirects to /login when no token', () => { | ||
| 33 | + renderGuard(['/'], { token: null, user: null }); | ||
| 34 | + expect(screen.getByTestId('login-sentinel')).toBeInTheDocument(); | ||
| 35 | + expect(screen.getByText(/from=\//)).toBeInTheDocument(); | ||
| 36 | + expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument(); | ||
| 37 | + }); | ||
| 38 | + | ||
| 39 | + it('renders Spin placeholder when token present but user not resolved', () => { | ||
| 40 | + renderGuard(['/'], { token: 't', user: null }); | ||
| 41 | + expect(screen.getByTestId('auth-resolving')).toBeInTheDocument(); | ||
| 42 | + expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument(); | ||
| 43 | + expect(screen.queryByTestId('login-sentinel')).not.toBeInTheDocument(); | ||
| 44 | + }); | ||
| 45 | + | ||
| 46 | + it('renders protected content when token and user ready', () => { | ||
| 47 | + renderGuard(['/'], { | ||
| 48 | + token: 't', | ||
| 49 | + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, | ||
| 50 | + }); | ||
| 51 | + expect(screen.getByTestId('protected-sentinel')).toBeInTheDocument(); | ||
| 52 | + }); | ||
| 53 | +}); |