fix(pda): revoke expired sessions before completing logout
This commit is contained in:
+11
-2
@@ -10,9 +10,10 @@ function authRequest(path, data, options = {}) {
|
||||
timeout: options.timeout || 15000,
|
||||
success(response) {
|
||||
const body = response.data || {}
|
||||
if (String(body.code ?? response.statusCode) !== '200') {
|
||||
if (response.statusCode !== 200 || String(body.code ?? response.statusCode) !== '200') {
|
||||
const error = new Error(body.message || '认证服务请求失败')
|
||||
error.code = body.code || response.statusCode
|
||||
error.statusCode = response.statusCode
|
||||
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
|
||||
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
|
||||
reject(error)
|
||||
@@ -33,9 +34,17 @@ export function login(loginId, password) {
|
||||
return authRequest('/auth/login', { loginId, password })
|
||||
}
|
||||
|
||||
const pendingRefreshes = new Map()
|
||||
export function refresh(refreshToken) {
|
||||
return authRequest('/auth/refresh', { refreshToken })
|
||||
if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken)
|
||||
const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => {
|
||||
if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken)
|
||||
})
|
||||
pendingRefreshes.set(refreshToken, pending)
|
||||
return pending
|
||||
}
|
||||
export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null }
|
||||
|
||||
|
||||
export function logout(refreshToken, accessToken) {
|
||||
return authRequest('/auth/logout', { refreshToken }, {
|
||||
|
||||
+20
-3
@@ -1,8 +1,9 @@
|
||||
import { refresh as refreshRequest } from '@/api/auth.js'
|
||||
import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js'
|
||||
import { isAccessTokenExpiring, session, signOut, updateTokenPair, getSessionRevision } from '@/stores/session.js'
|
||||
import { requireOnline } from '@/utils/network.js'
|
||||
|
||||
let refreshPromise = null
|
||||
let refreshRevision = null
|
||||
let redirectingToLogin = false
|
||||
|
||||
function responseCode(response) {
|
||||
@@ -66,14 +67,21 @@ function rawRequest(options, accessToken) {
|
||||
})
|
||||
}
|
||||
|
||||
function requireSameSession(revision) {
|
||||
if (getSessionRevision() !== revision) throw new Error('登录会话已变更,请重新操作')
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const revision = getSessionRevision()
|
||||
const observedRefreshToken = session.refreshToken
|
||||
if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
|
||||
if (!refreshPromise) {
|
||||
if (!refreshPromise || refreshRevision !== revision) {
|
||||
refreshRevision = revision
|
||||
refreshPromise = (async () => {
|
||||
if (session.refreshToken !== observedRefreshToken && session.token) return session.token
|
||||
try {
|
||||
const tokens = await refreshRequest(observedRefreshToken)
|
||||
requireSameSession(revision)
|
||||
if (session.refreshToken !== observedRefreshToken) {
|
||||
if (session.token) return session.token
|
||||
throw sessionExpiredError()
|
||||
@@ -82,10 +90,14 @@ async function refreshAccessToken() {
|
||||
redirectingToLogin = false
|
||||
return session.token
|
||||
} catch (error) {
|
||||
requireSameSession(revision)
|
||||
if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
|
||||
throw error
|
||||
}
|
||||
})().finally(() => { refreshPromise = null })
|
||||
})()
|
||||
const pending = refreshPromise
|
||||
const release = () => { if (refreshPromise === pending) { refreshPromise = null; refreshRevision = null } }
|
||||
pending.then(release, release) // Original pending promise still propagates failures to callers.
|
||||
}
|
||||
return refreshPromise
|
||||
}
|
||||
@@ -101,12 +113,17 @@ export function ensureAccessToken() {
|
||||
|
||||
async function runAuthenticated(execute) {
|
||||
requireOnline()
|
||||
const revision = getSessionRevision()
|
||||
let accessToken = await validAccessToken()
|
||||
requireSameSession(revision)
|
||||
let response = await execute(accessToken)
|
||||
requireSameSession(revision)
|
||||
if (!isUnauthorized(response)) return response
|
||||
accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
|
||||
requireSameSession(revision)
|
||||
const activeRefreshToken = session.refreshToken
|
||||
response = await execute(accessToken)
|
||||
requireSameSession(revision)
|
||||
if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { logout, refresh, getPendingRefresh } from './auth.js'
|
||||
import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
|
||||
|
||||
function replacement(captured, tokens) {
|
||||
if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) throw new Error('退出刷新响应缺少会话凭据')
|
||||
const previous = decodeAccessToken(captured.accessToken), next = decodeAccessToken(tokens.accessToken)
|
||||
if (!Number.isFinite(Number(next.exp)) || Number(next.exp) * 1000 <= Date.now()) throw new Error('退出刷新凭据无效或已过期')
|
||||
for (const field of ['user_id', 'membership_id', 'tenant_id', 'app_code']) {
|
||||
if (previous[field] == null || String(previous[field]) !== String(next[field])) throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
|
||||
}
|
||||
if (String(previous.domestic_warehouse_id ?? '') !== String(next.domestic_warehouse_id ?? '')) throw new Error('退出刷新返回了不同作业仓库,无法确认原会话撤销')
|
||||
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
|
||||
}
|
||||
export function logoutCurrentSession() {
|
||||
const captured = { accessToken: session.token, refreshToken: session.refreshToken, pendingRefresh: getPendingRefresh(session.refreshToken) }
|
||||
// Clear sensitive data and invalidate business requests while revocation is pending.
|
||||
signOut()
|
||||
const clearedRevision = getSessionRevision()
|
||||
const revoke = async () => {
|
||||
if (!captured.accessToken && !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
|
||||
if (!captured.accessToken || !captured.refreshToken) throw new Error('原会话凭据不完整,服务端退出未确认')
|
||||
let credentials = captured, refreshed = false
|
||||
if (captured.pendingRefresh) { refreshed = true; credentials = replacement(captured, await captured.pendingRefresh) }
|
||||
try { await logout(credentials.refreshToken, credentials.accessToken) }
|
||||
catch (error) {
|
||||
if (error.network || error.statusCode !== 401 || refreshed) throw error
|
||||
credentials = replacement(captured, await refresh(captured.refreshToken))
|
||||
await logout(credentials.refreshToken, credentials.accessToken)
|
||||
}
|
||||
return { revoked: true }
|
||||
}
|
||||
return revoke().then(result => ({ ...result, clearedRevision }), error => { error.logoutRevision = clearedRevision; throw error })
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">国内仓作业员</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view><button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button><button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button></view><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
||||
</template>
|
||||
<script>
|
||||
import { logout as logoutRequest } from '@/api/auth.js'
|
||||
import { logoutCurrentSession } from '@/api/logout-session.js'
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import PopupHeader from '@/components/PopupHeader.vue'
|
||||
import { session, requireSession, signOut } from '@/stores/session.js'
|
||||
import { session, requireSession, getSessionRevision } from '@/stores/session.js'
|
||||
import { checkForAppUpdate } from '@/utils/app-update.js'
|
||||
|
||||
export default {
|
||||
@@ -34,11 +34,20 @@
|
||||
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
||||
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
|
||||
logout() {
|
||||
const refreshToken = session.refreshToken
|
||||
const accessToken = session.token
|
||||
signOut()
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
if (refreshToken) logoutRequest(refreshToken, accessToken).catch(() => {})
|
||||
if (this._logoutPromise) return this._logoutPromise
|
||||
this.closeLogout()
|
||||
const operation = logoutCurrentSession()
|
||||
this.user = null
|
||||
this._logoutPromise = operation.then(result => {
|
||||
if (getSessionRevision() === result.clearedRevision) uni.reLaunch({ url: '/pages/login/login' })
|
||||
return result
|
||||
}, error => {
|
||||
if (getSessionRevision() === error.logoutRevision) {
|
||||
uni.reLaunch({ url: '/pages/login/login', complete() { uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none', duration: 3000 }) } })
|
||||
}
|
||||
return { revoked: false, error }
|
||||
})
|
||||
return this._logoutPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { decodeAccessToken } from '@/utils/jwt-claims.js'
|
||||
|
||||
export { decodeAccessToken } from '@/utils/jwt-claims.js'
|
||||
|
||||
let sessionRevision = 0
|
||||
export function getSessionRevision() { return sessionRevision }
|
||||
|
||||
const SESSION_KEY = 'pda-auth-session'
|
||||
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
|
||||
|
||||
@@ -53,6 +56,7 @@ function persistSession() {
|
||||
}
|
||||
|
||||
export function signIn(account, warehouse, options = {}) {
|
||||
sessionRevision++
|
||||
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null, domesticWarehouseId: account.domesticWarehouseId || null, warehouseBound: Boolean(account.domesticWarehouseId) }
|
||||
session.token = options.token || account.token || ''
|
||||
session.refreshToken = options.refreshToken || account.refreshToken || ''
|
||||
@@ -144,6 +148,7 @@ export function restoreSession() {
|
||||
tenantId: claims.tenant_id == null ? (saved.tenantId || '') : String(claims.tenant_id),
|
||||
apiBase: saved.apiBase || PLATFORM_API_ROOT
|
||||
})
|
||||
sessionRevision++
|
||||
persistSession()
|
||||
return true
|
||||
} catch (_) {
|
||||
@@ -153,6 +158,7 @@ export function restoreSession() {
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
sessionRevision++
|
||||
session.currentUser = null
|
||||
session.token = ''
|
||||
session.refreshToken = ''
|
||||
|
||||
@@ -57,11 +57,14 @@ test('confirmed session expiry clears sensitive state and redirects only once',
|
||||
assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
|
||||
})
|
||||
|
||||
test('manual logout revokes the refresh token without delaying local cleanup', async () => {
|
||||
const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')])
|
||||
assert.match(auth, /export function logout\(refreshToken, accessToken\)/)
|
||||
assert.match(profile, /const refreshToken = session\.refreshToken[\s\S]*?const accessToken = session\.token[\s\S]*?signOut\(\)[\s\S]*?uni\.reLaunch/)
|
||||
assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/)
|
||||
test('manual logout waits for captured-session revoke and honestly handles failure', async () => {
|
||||
const [profile, helper] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/logout-session.js')])
|
||||
assert.match(profile, /logoutCurrentSession\(\)/)
|
||||
assert.match(profile, /operation\.then/)
|
||||
assert.match(profile, /服务端退出未确认/)
|
||||
assert.match(helper, /signOut\(\)/)
|
||||
assert.match(helper, /error\.statusCode !== 401/)
|
||||
assert.doesNotMatch(profile, /catch\(\(\) => \{\}\)/)
|
||||
})
|
||||
|
||||
test('production login starts empty and remembers only the account preference', async () => {
|
||||
|
||||
@@ -103,7 +103,7 @@ test('workbench, module, history and profile are independent sources', async ()
|
||||
assert.match(module, /pages\/operation\/operation\?module=\$\{encodeURIComponent\(this\.moduleKey\)\}&name=/)
|
||||
assert.match(history, /作业记录/)
|
||||
assert.match(profile, /个人中心/)
|
||||
assert.match(profile, /signOut\(\)/)
|
||||
assert.match(profile, /logoutCurrentSession\(\)/)
|
||||
assert.match(profile, /class="logout-dialog-card"/)
|
||||
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { decodeAccessToken } from '../utils/jwt-claims.js'
|
||||
const source = path => fs.readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
const plain = text => text.replace(/^\s*(?:import|export \{)[^\n]*$/gm, '').replace(/export (?=function|const)/g, '')
|
||||
const pair = (version='old', identity={}) => ({ accessToken: `header.${Buffer.from(JSON.stringify({ user_id:'42', membership_id:'84', tenant_id:'0', app_code:'PLATFORM', domestic_warehouse_id:'900', exp:Math.floor(Date.now()/1000)+3600, version, ...identity })).toString('base64url')}.signature`, refreshToken:`refresh-${version}` })
|
||||
const ok = data => ({statusCode:200,data:{code:'200',data},header:{}})
|
||||
const denied = (statusCode=401,code='A0401') => ({statusCode,data:{code,message:'denied',requestId:'req',traceId:'trace'},header:{}})
|
||||
function harness() {
|
||||
const values=new Map(),calls=[],launches=[],toasts=[];let drafts={owner:'old'}
|
||||
globalThis.uni={setStorageSync:(k,v)=>values.set(k,structuredClone(v)),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>{launches.push(o.url);o.complete?.()},showToast:o=>toasts.push(o.title),request:o=>calls.push(o)}
|
||||
const s=new Function('reactive','resetPickingSession','PLATFORM_API_ROOT','decodeAccessToken',plain(source('../stores/session.js'))+';return {session,signInWithTokens,signOut,updateTokenPair,isAccessTokenExpiring,requireSession,getSessionRevision:typeof getSessionRevision === "function" ? getSessionRevision : () => 0}')(x=>x,()=>{drafts=null},'/api/platform/v1',decodeAccessToken)
|
||||
const auth=new Function('API_ROOT',plain(source('../api/auth.js'))+';return {logout,refresh,getPendingRefresh:typeof getPendingRefresh === "function" ? getPendingRefresh : () => null}')('/api')
|
||||
const path=new URL('../api/logout-session.js',import.meta.url)
|
||||
const helper=fs.existsSync(path)?new Function('logout','refresh','getPendingRefresh','session','signOut','getSessionRevision','decodeAccessToken',plain(fs.readFileSync(path,'utf8'))+';return {logoutCurrentSession}')(auth.logout,auth.refresh,auth.getPendingRefresh,s.session,s.signOut,s.getSessionRevision,decodeAccessToken):{}
|
||||
const script=source('../pages/profile/profile.vue').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import[^\n]*$/gm,'').replace('export default','return')
|
||||
const page=new Function('logoutRequest','PortalHeader','PopupHeader','session','requireSession','signOut','checkForAppUpdate','logoutCurrentSession','getSessionRevision',script)(auth.logout,{},{},s.session,s.requireSession,s.signOut,()=>{},helper.logoutCurrentSession,s.getSessionRevision)
|
||||
const original=pair();s.signInWithTokens(original)
|
||||
const http=new Function('refreshRequest','isAccessTokenExpiring','session','signOut','updateTokenPair','requireOnline','getSessionRevision',plain(source('../api/http.js'))+';return {apiRequest}')(auth.refresh,s.isAccessTokenExpiring,s.session,s.signOut,s.updateTokenPair,()=>{},s.getSessionRevision)
|
||||
return {...s,auth,helper,http,page,context:{user:s.session.currentUser,closeLogout(){}},original,values,calls,launches,toasts,get drafts(){return drafts}}
|
||||
}
|
||||
const tick=()=>new Promise(r=>setImmediate(r))
|
||||
test('profile waits for revoke before navigating and clears drafts immediately',async()=>{
|
||||
const h=harness(),pending=h.page.methods.logout.call(h.context)
|
||||
assert.equal(h.calls.length,1);assert.equal(h.session.token,'');assert.equal(h.drafts,null);assert.equal(h.launches.length,0)
|
||||
h.calls[0].success(ok(null));await pending;assert.deepEqual(h.launches,['/pages/login/login']);assert.equal(h.values.has('pda-auth-session'),false)
|
||||
})
|
||||
test('duplicate confirmation shares one pending revoke',async()=>{
|
||||
const h=harness(),a=h.page.methods.logout.call(h.context),b=h.page.methods.logout.call(h.context)
|
||||
assert.equal(h.calls.length,1);assert.ok(a && typeof a.then === 'function');assert.equal(a,b);h.calls[0].success(ok(null));await a
|
||||
})
|
||||
test('expired access 401 rotates once and revokes new pair without restoring local login',async()=>{
|
||||
const h=harness(),p=h.helper.logoutCurrentSession();h.calls[0].success(denied());await tick()
|
||||
assert.match(h.calls[1].url,/\/auth\/refresh$/);const rotated=pair('rotated');h.calls[1].success(ok(rotated));await tick()
|
||||
assert.deepEqual(h.calls[2].data,{refreshToken:rotated.refreshToken});assert.equal(h.calls[2].header.Authorization,`Bearer ${rotated.accessToken}`)
|
||||
h.calls[2].success(ok(null));assert.equal((await p).revoked,true);assert.equal(h.session.token,'');assert.equal(h.calls.length,3)
|
||||
})
|
||||
test('network unknown, 5xx and business rejection do not trigger blind retries',async()=>{
|
||||
for(const kind of ['network','server','business']) {
|
||||
const h=harness(),p=h.helper.logoutCurrentSession();const rejection=assert.rejects(p)
|
||||
if(kind==='network')h.calls[0].fail({errMsg:'lost'});else h.calls[0].success(denied(kind==='server'?503:200,kind==='server'?'S0503':'A0401'))
|
||||
await rejection;assert.equal(h.calls.length,1);assert.equal(h.session.token,'');assert.equal(h.drafts,null)
|
||||
}
|
||||
})
|
||||
test('failed profile logout warns instead of reporting confirmed revocation',async()=>{
|
||||
const h=harness(),p=h.page.methods.logout.call(h.context);h.calls[0].fail({errMsg:'lost'});const result=await p
|
||||
assert.equal(result.revoked,false);assert.deepEqual(h.toasts,['已清除本地登录,服务端退出未确认']);assert.equal(h.session.refreshToken,'')
|
||||
})
|
||||
test('identity mismatch or malformed rotated credentials cannot be used for another revoke',async()=>{
|
||||
for(const change of [{user_id:'43'},{membership_id:'85'},{domestic_warehouse_id:'901'},{exp:1}]) {
|
||||
const h=harness(),p=h.helper.logoutCurrentSession(),rejection=assert.rejects(p);h.calls[0].success(denied());await tick()
|
||||
h.calls[1].success(ok(pair('wrong',change)));await rejection;assert.equal(h.calls.length,2);assert.equal(h.session.token,'')
|
||||
}
|
||||
})
|
||||
test('second 401 is terminal with diagnostic evidence and no refresh loop',async()=>{
|
||||
const h=harness(),p=h.helper.logoutCurrentSession(),rejection=assert.rejects(p,e=>e.statusCode===401 && e.requestId==='req' && e.traceId==='trace')
|
||||
h.calls[0].success(denied());await tick();h.calls[1].success(ok(pair('rotated')));await tick();h.calls[2].success(denied());await rejection;assert.equal(h.calls.length,3)
|
||||
})
|
||||
test('logout joins in-flight automatic refresh, revokes its result, and never revives cleared session',async()=>{
|
||||
const h=harness();h.session.token=pair('expired',{exp:1}).accessToken
|
||||
const business=h.http.apiRequest({url:'/work'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||
const exit=h.helper.logoutCurrentSession();const newer=pair('new',{user_id:'99',membership_id:'98',domestic_warehouse_id:'950'});h.signInWithTokens(newer)
|
||||
const rotated=pair('rotated');h.calls[0].success(ok(rotated));await tick()
|
||||
assert.equal(h.calls.length,2);assert.match(h.calls[1].url,/\/auth\/logout$/);assert.deepEqual(h.calls[1].data,{refreshToken:rotated.refreshToken})
|
||||
h.calls[1].success(ok(null));await exit;assert.match((await business).message,/会话已变更/);assert.equal(h.session.token,newer.accessToken)
|
||||
})
|
||||
test('old logout success and failure cannot redirect or warn over a later new login',async()=>{
|
||||
for(const fail of [false,true]) {
|
||||
const h=harness(),p=h.page.methods.logout.call(h.context),newer=pair('new',{user_id:'99'});h.signInWithTokens(newer)
|
||||
if(fail)h.calls[0].fail({errMsg:'lost'});else h.calls[0].success(ok(null));await p
|
||||
assert.equal(h.session.token,newer.accessToken);assert.equal(h.launches.length,0);assert.equal(h.toasts.length,0)
|
||||
}
|
||||
})
|
||||
test('unknown pending refresh is not repeated and its failure remains observable',async()=>{
|
||||
const h=harness(),refresh=h.auth.refresh(h.original.refreshToken).catch(e=>e),exit=h.helper.logoutCurrentSession(),rejected=assert.rejects(exit,e=>e.network===true)
|
||||
h.calls[0].fail({errMsg:'lost refresh'});await rejected;await refresh;assert.equal(h.calls.length,1);assert.equal(h.session.token,'')
|
||||
})
|
||||
test('stale business 401 after logout cannot refresh or clear a newer login',async()=>{
|
||||
const h=harness(),business=h.http.apiRequest({url:'/work'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||
const exit=h.helper.logoutCurrentSession(),newer=pair('new',{user_id:'99'});h.signInWithTokens(newer)
|
||||
h.calls[0].success(denied());h.calls[1].success(ok(null));await exit
|
||||
assert.match((await business).message,/会话已变更/);assert.equal(h.calls.length,2);assert.equal(h.session.token,newer.accessToken)
|
||||
})
|
||||
test('concurrent automatic requests share one refresh and are rejected after logout without business replay',async()=>{
|
||||
const h=harness();h.session.token=pair('expired',{exp:1}).accessToken
|
||||
const a=h.http.apiRequest({url:'/one'}).catch(e=>e),b=h.http.apiRequest({url:'/two'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||
const exit=h.helper.logoutCurrentSession();h.calls[0].success(ok(pair('rotated')));await tick();assert.equal(h.calls.length,2)
|
||||
h.calls[1].success(ok(null));await exit
|
||||
assert.match((await a).message,/会话已变更/);assert.match((await b).message,/会话已变更/);assert.equal(h.session.token,'')
|
||||
})
|
||||
test('missing rotated token fields fail closed and never report a revoked session',async()=>{
|
||||
for(const field of ['accessToken','refreshToken']) {
|
||||
const h=harness(),exit=h.helper.logoutCurrentSession(),rejected=assert.rejects(exit);h.calls[0].success(denied());await tick()
|
||||
const bad=pair('bad');delete bad[field];h.calls[1].success(ok(bad));await rejected;assert.equal(h.calls.length,2);assert.equal(h.session.token,'')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user