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 | +}); | ... | ... |