fix(auth): dedupe concurrent session expiry
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import { PLATFORM_UNAUTHORIZED_EVENT } from '../v2/auth/session';
|
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from '../v2/auth/session';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -28,11 +28,51 @@ test('a protected 401 terminates the client session but login validation errors
|
|||||||
|
|
||||||
await expect(api.monitorSummary()).rejects.toThrow('访问令牌无效');
|
await expect(api.monitorSummary()).rejects.toThrow('访问令牌无效');
|
||||||
expect(unauthorized).toHaveBeenCalledTimes(1);
|
expect(unauthorized).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getAccessToken()).toBe('');
|
||||||
await expect(api.session()).rejects.toThrow('访问令牌无效');
|
await expect(api.session()).rejects.toThrow('访问令牌无效');
|
||||||
expect(unauthorized).toHaveBeenCalledTimes(1);
|
expect(unauthorized).toHaveBeenCalledTimes(1);
|
||||||
window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, unauthorized);
|
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<Response>((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 () => {
|
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);
|
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 });
|
await api.alertEventsV2({ status: 'unprocessed', limit: 20, offset: 0 });
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init;
|
const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init;
|
||||||
const response = await fetch(path, requestInit);
|
const response = await fetch(path, requestInit);
|
||||||
if (!response.ok) {
|
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));
|
throw new Error(await responseErrorMessage(response));
|
||||||
}
|
}
|
||||||
const envelope = (await response.json()) as ApiEnvelope<T>;
|
const envelope = (await response.json()) as ApiEnvelope<T>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, expect, test } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
import { canAdminister, canOperate, clearAccessToken, getAccessToken, setAccessToken } from './session';
|
import { canAdminister, canOperate, clearAccessToken, getAccessToken, notifyUnauthorizedSession, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
window.sessionStorage.clear();
|
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: 'o', role: 'operator', authMode: 'enforce' })).toBe(false);
|
||||||
expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ export function clearAccessToken() {
|
|||||||
window.sessionStorage.removeItem(TOKEN_KEY);
|
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));
|
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canOperate(session: PlatformSession) {
|
export function canOperate(session: PlatformSession) {
|
||||||
|
|||||||
Reference in New Issue
Block a user