diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 6bd5c3e8..23ab42c9 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test, vi } from 'vitest'; import { api } from './client'; -import { PLATFORM_UNAUTHORIZED_EVENT } from '../v2/auth/session'; +import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from '../v2/auth/session'; afterEach(() => { vi.restoreAllMocks(); @@ -28,11 +28,51 @@ test('a protected 401 terminates the client session but login validation errors await expect(api.monitorSummary()).rejects.toThrow('访问令牌无效'); expect(unauthorized).toHaveBeenCalledTimes(1); + expect(getAccessToken()).toBe(''); await expect(api.session()).rejects.toThrow('访问令牌无效'); expect(unauthorized).toHaveBeenCalledTimes(1); window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized); }); +test('parallel protected 401 responses invalidate one client session exactly once', async () => { + setAccessToken('expired-parallel-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); + + const results = await Promise.allSettled([api.monitorSummary(), api.metricCatalog(), api.opsHealth()]); + + expect(results.every((result) => result.status === 'rejected')).toBe(true); + expect(unauthorized).toHaveBeenCalledTimes(1); + expect(getAccessToken()).toBe(''); + window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized); +}); + +test('a delayed 401 from an old token cannot terminate a newer login', async () => { + setAccessToken('old-token'); + const unauthorized = vi.fn(); + window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized); + let resolveResponse!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((resolve) => { resolveResponse = resolve; })); + + const oldRequest = api.monitorSummary(); + setAccessToken('new-token'); + resolveResponse({ + ok: false, + status: 401, + json: async () => ({ error: { message: '旧令牌已过期' } }) + } as Response); + + await expect(oldRequest).rejects.toThrow('旧令牌已过期'); + expect(unauthorized).not.toHaveBeenCalled(); + expect(getAccessToken()).toBe('new-token'); + 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 }); diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 8153e520..a51e59ed 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -90,7 +90,7 @@ async function request(path: string, init?: RequestInit): Promise { 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(); + if (response.status === 401 && token && path !== '/api/v2/session') notifyUnauthorizedSession(token); throw new Error(await responseErrorMessage(response)); } const envelope = (await response.json()) as ApiEnvelope; diff --git a/vehicle-data-platform/apps/web/src/v2/auth/session.test.ts b/vehicle-data-platform/apps/web/src/v2/auth/session.test.ts index 0cb912d9..f543088f 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/session.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/auth/session.test.ts @@ -1,5 +1,5 @@ -import { afterEach, expect, test } from 'vitest'; -import { canAdminister, canOperate, clearAccessToken, getAccessToken, setAccessToken } from './session'; +import { afterEach, expect, test, vi } from 'vitest'; +import { canAdminister, canOperate, clearAccessToken, getAccessToken, notifyUnauthorizedSession, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session'; afterEach(() => { window.sessionStorage.clear(); @@ -20,3 +20,18 @@ test('role helpers follow the server permission hierarchy', () => { expect(canAdminister({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(false); expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true); }); + +test('invalidates only the currently active rejected token', () => { + const unauthorized = vi.fn(); + window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized); + setAccessToken('new-token'); + + expect(notifyUnauthorizedSession('old-token')).toBe(false); + expect(getAccessToken()).toBe('new-token'); + expect(notifyUnauthorizedSession('new-token')).toBe(true); + expect(getAccessToken()).toBe(''); + expect(notifyUnauthorizedSession('new-token')).toBe(false); + expect(unauthorized).toHaveBeenCalledTimes(1); + + window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/auth/session.ts b/vehicle-data-platform/apps/web/src/v2/auth/session.ts index 49f060ab..bf87a228 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/session.ts +++ b/vehicle-data-platform/apps/web/src/v2/auth/session.ts @@ -23,8 +23,11 @@ export function clearAccessToken() { window.sessionStorage.removeItem(TOKEN_KEY); } -export function notifyUnauthorizedSession() { +export function notifyUnauthorizedSession(rejectedToken: string) { + if (!rejectedToken || getAccessToken() !== rejectedToken) return false; + clearAccessToken(); window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT)); + return true; } export function canOperate(session: PlatformSession) {