feat(frontend): T1-04 verify admin auth session
This commit is contained in:
@@ -1,29 +1,53 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { IntlProvider } from 'react-intl';
|
||||||
import { RequireAuth } from './RequireAuth';
|
import { RequireAuth } from './RequireAuth';
|
||||||
|
import { messages } from '@/i18n/messages';
|
||||||
import { useUserStore } from '@/stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
|
|
||||||
function renderGuard(initialPath = '/admin') {
|
function renderGuard(initialPath = '/admin') {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[initialPath]}>
|
<IntlProvider locale="zh-CN" messages={messages['zh-CN']} onError={() => undefined}>
|
||||||
<Routes>
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
<Route
|
<Routes>
|
||||||
path="/admin"
|
<Route
|
||||||
element={
|
path="/admin"
|
||||||
<RequireAuth>
|
element={
|
||||||
<main>后台内容</main>
|
<RequireAuth>
|
||||||
</RequireAuth>
|
<main>后台内容</main>
|
||||||
}
|
</RequireAuth>
|
||||||
/>
|
}
|
||||||
<Route path="/admin/login" element={<main>登录页</main>} />
|
/>
|
||||||
<Route path="/403" element={<main>权限不足</main>} />
|
<Route path="/admin/login" element={<main>登录页</main>} />
|
||||||
</Routes>
|
<Route path="/403" element={<main>权限不足</main>} />
|
||||||
</MemoryRouter>,
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</IntlProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AdminSessionOverrides {
|
||||||
|
tokenExpiresAt?: number;
|
||||||
|
role?: 'user' | 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedAdminSession(overrides: AdminSessionOverrides = {}): void {
|
||||||
|
useUserStore.getState().setAdminSession({
|
||||||
|
username: 'admin',
|
||||||
|
accessToken: 'jwt-token',
|
||||||
|
tokenType: 'bearer',
|
||||||
|
expiresIn: 3600,
|
||||||
|
});
|
||||||
|
if (overrides.tokenExpiresAt !== undefined) {
|
||||||
|
useUserStore.setState({ tokenExpiresAt: overrides.tokenExpiresAt });
|
||||||
|
}
|
||||||
|
if (overrides.role !== undefined) {
|
||||||
|
useUserStore.setState({ role: overrides.role });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('RequireAuth', () => {
|
describe('RequireAuth', () => {
|
||||||
it('redirects anonymous users to admin login', async () => {
|
it('redirects anonymous users to admin login', async () => {
|
||||||
renderGuard();
|
renderGuard();
|
||||||
@@ -31,20 +55,7 @@ describe('RequireAuth', () => {
|
|||||||
expect(await screen.findByText('登录页')).toBeInTheDocument();
|
expect(await screen.findByText('登录页')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects non-admin users to forbidden page', async () => {
|
it('redirects users without an active token to admin login', async () => {
|
||||||
useUserStore.getState().setUser({
|
|
||||||
id: 'user-1',
|
|
||||||
name: '普通用户',
|
|
||||||
phone: '13800138000',
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
renderGuard();
|
|
||||||
|
|
||||||
expect(await screen.findByText('权限不足')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders children for admin users', () => {
|
|
||||||
useUserStore.getState().setUser({
|
useUserStore.getState().setUser({
|
||||||
id: 'admin-1',
|
id: 'admin-1',
|
||||||
name: '管理员',
|
name: '管理员',
|
||||||
@@ -54,6 +65,71 @@ describe('RequireAuth', () => {
|
|||||||
|
|
||||||
renderGuard();
|
renderGuard();
|
||||||
|
|
||||||
expect(screen.getByText('后台内容')).toBeInTheDocument();
|
expect(await screen.findByText('登录页')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects expired admin sessions to admin login and clears auth state', async () => {
|
||||||
|
seedAdminSession({ tokenExpiresAt: Date.now() - 1000 });
|
||||||
|
|
||||||
|
renderGuard();
|
||||||
|
|
||||||
|
expect(await screen.findByText('登录页')).toBeInTheDocument();
|
||||||
|
expect(useUserStore.getState()).toMatchObject({ isLoggedIn: false, token: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects non-admin users to forbidden page after backend verification', async () => {
|
||||||
|
seedAdminSession({ role: 'user' });
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ id: 1, username: 'viewer', role: 'viewer', is_active: true, created_at: '2026-07-05T00:00:00+00:00' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderGuard();
|
||||||
|
|
||||||
|
expect(await screen.findByText('权限不足')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies the token with /api/auth/me before rendering children for admin users', async () => {
|
||||||
|
seedAdminSession();
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ id: 1, username: 'admin', role: 'admin', is_active: true, created_at: '2026-07-05T00:00:00+00:00' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
renderGuard();
|
||||||
|
|
||||||
|
expect(await screen.findByText('后台内容')).toBeInTheDocument();
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const calls = fetchMock.mock.calls as Array<[string, RequestInit]>;
|
||||||
|
expect(calls[0][0]).toBe('/api/auth/me');
|
||||||
|
expect(calls[0][1].method).toBe('GET');
|
||||||
|
const headers = calls[0][1].headers as Record<string, string>;
|
||||||
|
expect(headers.Authorization).toBe('Bearer jwt-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs out and redirects to login when backend verification rejects the token', async () => {
|
||||||
|
seedAdminSession();
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ code: 'E01202', message: 'invalid token' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderGuard();
|
||||||
|
|
||||||
|
expect(await screen.findByText('登录页')).toBeInTheDocument();
|
||||||
|
expect(useUserStore.getState()).toMatchObject({ isLoggedIn: false, token: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RouteFallback } from '@/components/shared/RouteFallback';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useUserStore } from '@/stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
|
|
||||||
interface RequireAuthProps {
|
interface RequireAuthProps {
|
||||||
@@ -7,18 +10,81 @@ interface RequireAuthProps {
|
|||||||
requireAdmin?: boolean;
|
requireAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentUserSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
username: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
is_active: z.boolean(),
|
||||||
|
created_at: z.string(),
|
||||||
|
last_login_at: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AuthCheckState = 'checking' | 'authenticated' | 'anonymous' | 'forbidden';
|
||||||
|
|
||||||
|
function hasActiveToken(token: string | null, tokenExpiresAt: number | null): boolean {
|
||||||
|
return Boolean(token && tokenExpiresAt && tokenExpiresAt > Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
export function RequireAuth({ children, requireAdmin = true }: RequireAuthProps) {
|
export function RequireAuth({ children, requireAdmin = true }: RequireAuthProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLoggedIn = useUserStore((state) => state.isLoggedIn);
|
const token = useUserStore((state) => state.token);
|
||||||
const role = useUserStore((state) => state.role);
|
const tokenExpiresAt = useUserStore((state) => state.tokenExpiresAt);
|
||||||
|
const logout = useUserStore((state) => state.logout);
|
||||||
|
const [authState, setAuthState] = useState<AuthCheckState>(() => (hasActiveToken(token, tokenExpiresAt) ? 'checking' : 'anonymous'));
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (!hasActiveToken(token, tokenExpiresAt)) {
|
||||||
|
logout();
|
||||||
|
setAuthState('anonymous');
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState('checking');
|
||||||
|
void apiClient
|
||||||
|
.get('/auth/me', currentUserSchema)
|
||||||
|
.then((user) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!user.is_active) {
|
||||||
|
logout();
|
||||||
|
setAuthState('anonymous');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requireAdmin && user.role !== 'admin') {
|
||||||
|
setAuthState('forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAuthState('authenticated');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logout();
|
||||||
|
setAuthState('anonymous');
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [logout, requireAdmin, token, tokenExpiresAt]);
|
||||||
|
|
||||||
|
if (authState === 'anonymous') {
|
||||||
return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
|
return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireAdmin && role !== 'admin') {
|
if (authState === 'forbidden') {
|
||||||
return <Navigate to="/403" replace />;
|
return <Navigate to="/403" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authState === 'checking') {
|
||||||
|
return <RouteFallback />;
|
||||||
|
}
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user