fix(auth): isolate client cache by session
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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>;
|
||||
|
||||
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