98 lines
9.3 KiB
JavaScript
98 lines
9.3 KiB
JavaScript
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,'')
|
|
}
|
|
})
|