滑动续期与多标签同步、列表报错提示、分页排序体验改进
This commit is contained in:
+109
-3
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { request, ApiError } from './request'
|
||||
import { createPinia, disposePinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { request, rawFetch, ApiError } from './request'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/** happy-dom 提供的当前页 URL 控制入口(标准 DOM 无此能力) */
|
||||
@@ -43,14 +43,17 @@ function stubRefreshThen401() {
|
||||
}
|
||||
|
||||
let assignSpy: ReturnType<typeof vi.spyOn>
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
assignSpy = vi.spyOn(location, 'assign').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
disposePinia(pinia)
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -159,6 +162,22 @@ describe('401 令牌快照', () => {
|
||||
expect(assignSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('storage 事件尚未送达时,401 也不得删除其他标签页刚写入的新 token', async () => {
|
||||
setPageURL('http://localhost/settings')
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
localStorage.setItem('oci-portal.token', 'tokB')
|
||||
localStorage.setItem('oci-portal.expiresAt', '2099-06-01T00:00:00Z')
|
||||
return { ok: false, status: 401, json: async () => ({ error: '令牌已失效' }) } as Response
|
||||
})
|
||||
|
||||
await expect(request('/x')).rejects.toMatchObject({ status: 401 })
|
||||
expect(auth.token).toBe('tokB')
|
||||
expect(localStorage.getItem('oci-portal.token')).toBe('tokB')
|
||||
expect(assignSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('401 先到时等待并发换新响应落入 Store,不得提前登出', async () => {
|
||||
setPageURL('http://localhost/settings')
|
||||
useAuthStore().setSession('tokA', '')
|
||||
@@ -228,3 +247,90 @@ describe('401 令牌快照', () => {
|
||||
expect(assignSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('滑动续期响应头', () => {
|
||||
function stubRenewHeaders(onFetch?: () => void, body = '{}') {
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
onFetch?.()
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'X-Renewed-Token': 'tokB',
|
||||
'X-Renewed-Expires-At': '2099-06-01T00:00:00Z',
|
||||
}),
|
||||
text: async () => body,
|
||||
} as unknown as Response
|
||||
})
|
||||
}
|
||||
|
||||
it('携带续期头时更新本地会话', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubRenewHeaders()
|
||||
await request('/instances')
|
||||
expect(auth.token).toBe('tokB')
|
||||
expect(auth.expiresAt).toBe('2099-06-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('发送时令牌已非当前会话则不应用续期头', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
// 响应返回前会话被并发换发(如 OAuth 绑定回跳),迟到的续期头不得覆盖
|
||||
stubRenewHeaders(() => auth.setSession('tokC', '2099-02-01T00:00:00Z'))
|
||||
await request('/instances')
|
||||
expect(auth.token).toBe('tokC')
|
||||
})
|
||||
|
||||
it('storage 事件尚未送达时也不让迟到续期头覆盖其他标签页的新会话', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubRenewHeaders(() => {
|
||||
localStorage.setItem('oci-portal.token', 'tokC')
|
||||
localStorage.setItem('oci-portal.expiresAt', '2099-07-01T00:00:00Z')
|
||||
})
|
||||
|
||||
await request('/instances')
|
||||
expect(auth.token).toBe('tokC')
|
||||
expect(localStorage.getItem('oci-portal.token')).toBe('tokC')
|
||||
})
|
||||
|
||||
it('显式 body 会话换发优先于续期头', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubRenewHeaders(undefined, '{"token":"tokC","expiresAt":"2099-07-01T00:00:00Z"}')
|
||||
await request('/refresh', { method: 'POST', refreshesSession: true })
|
||||
expect(auth.token).toBe('tokC')
|
||||
expect(auth.expiresAt).toBe('2099-07-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('普通失败响应携带续期头也不得更新会话', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubFailedRenewHeaders()
|
||||
await expect(request('/instances')).rejects.toMatchObject({ status: 500 })
|
||||
expect(auth.token).toBe('tokA')
|
||||
})
|
||||
|
||||
it('rawFetch 失败响应携带续期头也不得更新会话', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubFailedRenewHeaders()
|
||||
await expect(rawFetch('/download')).rejects.toMatchObject({ status: 500 })
|
||||
expect(auth.token).toBe('tokA')
|
||||
})
|
||||
|
||||
function stubFailedRenewHeaders() {
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
headers: new Headers({
|
||||
'X-Renewed-Token': 'tokB',
|
||||
'X-Renewed-Expires-At': '2099-06-01T00:00:00Z',
|
||||
}),
|
||||
json: async () => ({ error: '服务器内部错误' }),
|
||||
} as unknown as Response
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+21
-4
@@ -92,6 +92,7 @@ async function handleUnauthorized(sentToken: string) {
|
||||
const waited = await waitForSessionRefresh(sentToken)
|
||||
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
const auth = useAuthStore()
|
||||
auth.syncFromStorage()
|
||||
if (sentToken !== auth.token) return
|
||||
auth.logout()
|
||||
if (location.pathname !== '/login') {
|
||||
@@ -140,11 +141,25 @@ function trackSessionRefresh(token: string): () => void {
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionRefresh(value: unknown) {
|
||||
if (!value || typeof value !== 'object') return
|
||||
/** 滑动续期:后端对临过期令牌在响应头中换发同会话新令牌;仅当发送时令牌仍是
|
||||
* 当前会话令牌才应用(与 401 处理同一快照原则,防旧会话迟到响应覆盖新会话)。 */
|
||||
function applyRenewedSession(resp: Response, sentToken: string) {
|
||||
// 测试环境的最小 Response stub 可能无 headers
|
||||
const token = resp.headers?.get('X-Renewed-Token')
|
||||
const expiresAt = resp.headers?.get('X-Renewed-Expires-At')
|
||||
if (!token || !expiresAt || !sentToken) return
|
||||
const auth = useAuthStore()
|
||||
auth.syncFromStorage()
|
||||
if (sentToken !== auth.token) return
|
||||
auth.setSession(token, expiresAt)
|
||||
}
|
||||
|
||||
function applySessionRefresh(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const refresh = value as { token?: unknown; expiresAt?: unknown }
|
||||
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return
|
||||
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return false
|
||||
useAuthStore().setSession(refresh.token, refresh.expiresAt)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError;
|
||||
@@ -190,7 +205,8 @@ async function doRequest<T>(
|
||||
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
|
||||
const text = await resp.text()
|
||||
const result = (text ? JSON.parse(text) : undefined) as T
|
||||
if (opts.refreshesSession && sentToken) applySessionRefresh(result)
|
||||
const bodyRefreshed = !!sentToken && !!opts.refreshesSession && applySessionRefresh(result)
|
||||
if (!bodyRefreshed) applyRenewedSession(resp, sentToken ?? '')
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -214,6 +230,7 @@ export async function rawFetch(
|
||||
body: opts.body,
|
||||
})
|
||||
if (!resp.ok) await parseError(resp, sentToken)
|
||||
applyRenewedSession(resp, sentToken ?? '')
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user