滑动续期与多标签同步、列表报错提示、分页排序体验改进

This commit is contained in:
2026-08-11 11:44:57 +08:00
parent ec8a50c4c0
commit 343563bb79
18 changed files with 358 additions and 24 deletions
+109 -3
View File
@@ -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
View File
@@ -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
}