fix(auth): isolate client cache by session

This commit is contained in:
lingniu
2026-07-16 04:26:20 +08:00
parent b39917d9d6
commit 6b4a0726b2
6 changed files with 131 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
import { afterEach, expect, test, vi } from 'vitest';
import { api } from './client';
import { PLATFORM_UNAUTHORIZED_EVENT } from '../v2/auth/session';
afterEach(() => {
vi.restoreAllMocks();
@@ -15,6 +16,23 @@ test('authenticated requests use the session-only bearer token', async () => {
expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull();
});
test('a protected 401 terminates the client session but login validation errors stay local', async () => {
window.sessionStorage.setItem('vehicle-platform.access-token', 'expired-token');
const unauthorized = vi.fn();
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: { message: '访问令牌无效' } })
} as Response);
await expect(api.monitorSummary()).rejects.toThrow('访问令牌无效');
expect(unauthorized).toHaveBeenCalledTimes(1);
await expect(api.session()).rejects.toThrow('访问令牌无效');
expect(unauthorized).toHaveBeenCalledTimes(1);
window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
});
test('durable alert APIs keep versioned actions, rules and notification reads explicit', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-alert', timestamp: 1 }) } as Response);
await api.alertEventsV2({ status: 'unprocessed', limit: 20, offset: 0 });

View File

@@ -55,7 +55,7 @@ import type {
VehicleServiceSummary,
VehicleRow
} from './types';
import { getAccessToken } from '../v2/auth/session';
import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session';
export type RawFrameQuery = {
keyword?: string;
@@ -90,6 +90,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init;
const response = await fetch(path, requestInit);
if (!response.ok) {
if (response.status === 401 && token && path !== '/api/v2/session') notifyUnauthorizedSession();
throw new Error(await responseErrorMessage(response));
}
const envelope = (await response.json()) as ApiEnvelope<T>;

View 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('');
});

View File

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

View File

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