fix(auth): isolate client cache by session
This commit is contained in:
73
vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx
Normal file
73
vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { QueryClient, QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
|
||||
import { AuthGate, usePlatformSession } from './AuthGate';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: { session: mocks.session } }));
|
||||
|
||||
function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
|
||||
const { session, logout } = usePlatformSession();
|
||||
useQuery({
|
||||
queryKey: ['protected-vehicle-data'],
|
||||
queryFn: ({ signal }) => new Promise(() => signal.addEventListener('abort', onAbort, { once: true })),
|
||||
retry: false
|
||||
});
|
||||
const mutation = useMutation({ mutationFn: async () => ({ ok: true }) });
|
||||
return <div><strong>{session.name}</strong><button type="button" onClick={() => mutation.mutate()}>创建处置缓存</button><button type="button" onClick={logout}>退出测试会话</button></div>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mocks.session.mockReset();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
test('treats login and logout as complete query and mutation cache boundaries', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(['previous-user-vehicle-data'], { plate: '粤A00001' });
|
||||
client.getMutationCache().build(client, { mutationFn: async () => ({ ok: true }) });
|
||||
const aborted = vi.fn();
|
||||
mocks.session.mockImplementation(async () => {
|
||||
if (getAccessToken() !== 'next-user-token') throw new Error('未登录');
|
||||
return { name: 'next-user', role: 'viewer', authMode: 'enforce' };
|
||||
});
|
||||
|
||||
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={aborted} /></AuthGate></QueryClientProvider>);
|
||||
const token = await screen.findByPlaceholderText('Bearer token');
|
||||
fireEvent.change(token, { target: { value: 'next-user-token' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入平台' }));
|
||||
|
||||
expect(await screen.findByText('next-user')).toBeInTheDocument();
|
||||
expect(client.getQueryData(['previous-user-vehicle-data'])).toBeUndefined();
|
||||
expect(client.getMutationCache().getAll()).toHaveLength(0);
|
||||
await waitFor(() => expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeDefined());
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建处置缓存' }));
|
||||
await waitFor(() => expect(client.getMutationCache().getAll()).toHaveLength(1));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出测试会话' }));
|
||||
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument();
|
||||
await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1));
|
||||
expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeUndefined();
|
||||
expect(client.getMutationCache().getAll()).toHaveLength(0);
|
||||
expect(window.sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
test('an expired protected request returns to login and removes the active user cache', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
setAccessToken('next-user-token');
|
||||
mocks.session.mockImplementation(async () => {
|
||||
if (getAccessToken() !== 'next-user-token') throw new Error('未登录');
|
||||
return { name: 'active-user', role: 'viewer', authMode: 'enforce' };
|
||||
});
|
||||
|
||||
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={() => undefined} /></AuthGate></QueryClientProvider>);
|
||||
expect(await screen.findByText('active-user')).toBeInTheDocument();
|
||||
client.setQueryData(['active-user-detail'], { plate: '粤A00002' });
|
||||
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT));
|
||||
|
||||
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument();
|
||||
expect(client.getQueryCache().find({ queryKey: ['active-user-detail'] })).toBeUndefined();
|
||||
expect(getAccessToken()).toBe('');
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createContext, FormEvent, ReactNode, useContext, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createContext, FormEvent, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { clearAccessToken, getAccessToken, PlatformSession, setAccessToken } from './session';
|
||||
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
|
||||
|
||||
type AuthContextValue = {
|
||||
session: PlatformSession;
|
||||
@@ -17,6 +17,7 @@ export function usePlatformSession() {
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [tokenVersion, setTokenVersion] = useState(0);
|
||||
const [draftToken, setDraftToken] = useState('');
|
||||
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
|
||||
@@ -26,19 +27,29 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
retry: false,
|
||||
staleTime: Infinity
|
||||
});
|
||||
const clearClientSession = useCallback(() => {
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
const logout = useCallback(() => {
|
||||
clearAccessToken();
|
||||
clearClientSession();
|
||||
setDraftToken('');
|
||||
setAttempted(false);
|
||||
setTokenVersion((value) => value + 1);
|
||||
}, [clearClientSession]);
|
||||
useEffect(() => {
|
||||
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout);
|
||||
return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout);
|
||||
}, [logout]);
|
||||
|
||||
const login = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setAccessToken(draftToken);
|
||||
clearClientSession();
|
||||
setAttempted(true);
|
||||
setTokenVersion((value) => value + 1);
|
||||
};
|
||||
const logout = () => {
|
||||
clearAccessToken();
|
||||
setDraftToken('');
|
||||
setAttempted(false);
|
||||
setTokenVersion((value) => value + 1);
|
||||
};
|
||||
|
||||
if (session.isPending) {
|
||||
return <div className="v2-auth-screen"><div className="v2-auth-card"><i className="v2-auth-spinner" /><strong>正在验证访问身份…</strong></div></div>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const TOKEN_KEY = 'vehicle-platform.access-token';
|
||||
export const PLATFORM_UNAUTHORIZED_EVENT = 'vehicle-platform:unauthorized';
|
||||
|
||||
export type PlatformRole = 'viewer' | 'operator' | 'admin';
|
||||
|
||||
@@ -22,6 +23,10 @@ export function clearAccessToken() {
|
||||
window.sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function notifyUnauthorizedSession() {
|
||||
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT));
|
||||
}
|
||||
|
||||
export function canOperate(session: PlatformSession) {
|
||||
return session.role === 'operator' || session.role === 'admin';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user