feat(frontend): T1-04 verify admin auth session
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-07-05 22:04:41 +08:00
parent 0d9e1ddb6e
commit ec6398197b
2 changed files with 177 additions and 35 deletions

View File

@@ -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 });
}); });
}); });

View File

@@ -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;
} }