const assert = require('node:assert/strict');
|
const fs = require('node:fs');
|
const path = require('node:path');
|
const ts = require('typescript');
|
const vue = require('vue');
|
const pinia = require('pinia');
|
const root = path.join(__dirname, '..');
|
function load(file, mocks = {}) {
|
const source = fs.readFileSync(path.join(root, file), 'utf8')
|
.replaceAll('import.meta.hot', 'undefined')
|
.replaceAll('import.meta.env', '({ DEV: true })');
|
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText;
|
const module = { exports: {} };
|
new Function('require', 'module', 'exports', 'localStorage', js)((id) => {
|
if (id in mocks) return mocks[id];
|
if (id === 'vue') return vue;
|
if (id === 'pinia') return pinia;
|
throw new Error(`Unexpected import ${id} in ${file}`);
|
}, module, module.exports, {});
|
return module.exports;
|
}
|
const constants = load('src/views/im/utils/constants.ts');
|
async function scenario(apiFails, resetFails, redirect) {
|
const app = vue.createApp({});
|
const setup = load('src/packages/stores/src/setup.ts', {
|
'secure-ls': class {}, 'pinia-plugin-persistedstate': { createPersistedState: () => () => {} },
|
});
|
const instance = await setup.initStores(app, { namespace: 'logout-test' });
|
pinia.setActivePinia(instance);
|
const company = load('src/store/company-config.ts', {
|
'#/api/system/company-config': {}, '#/utils/company-config': {},
|
}).useCompanyConfigStore();
|
company.companyName = 'Test company';
|
company.logoUrl = '/test-logo.png';
|
company.loginTitle = 'Test';
|
company.loginDescription = 'Test description';
|
const ui = load('src/views/im/home/store/uiStore.ts', { '../../utils/constants': constants }).useImUiStore();
|
ui.userInfoCard.show = true;
|
ui.userInfoCard.user = { id: 1 };
|
ui.contextMenu.items = [{ key: 'private', name: 'private' }];
|
ui.contextMenu.onSelect = () => {};
|
ui.nextUnreadJumpNonce = 3;
|
const rtc = load('src/views/im/home/store/rtcStore.ts', {
|
'../../utils/constants': constants, '#/views/im/utils/auth': {}, './friendStore': {},
|
'./groupStore': { useGroupStore: () => ({ markGroupActiveCallLoaded: () => {} }) },
|
}).useRtcStore();
|
rtc.stage = constants.ImRtcCallStage.RUNNING;
|
rtc.call = { id: 1 };
|
rtc.setGroupCall({ groupId: 1, room: 'test', joinedUserIds: [1], inviteeIds: [] });
|
const accessModule = load('src/packages/stores/src/modules/access.ts');
|
const userModule = load('src/packages/stores/src/modules/user.ts');
|
const access = accessModule.useAccessStore();
|
access.setAccessToken('old-access');
|
access.setRefreshToken('old-refresh');
|
access.setIsAccessChecked(true);
|
access.setLoginExpired(true);
|
userModule.useUserStore().setUserInfo({ userId: 1 });
|
if (resetFails) pinia.defineStore('broken-business-store', () => ({ count: vue.ref(1) }))();
|
const navigations = [];
|
const apiCalls = [];
|
const auth = load('src/store/auth.ts', {
|
'vue-router': { useRouter: () => ({ currentRoute: vue.ref({ fullPath: '/analytics?tab=1' }), replace: async (to) => navigations.push(to) }) },
|
'@vben/constants': { LOGIN_PATH: '/auth/login' }, '@vben/preferences': {},
|
'@vben/stores': { ...setup, ...accessModule, ...userModule }, 'ant-design-vue': {}, '#/locales': {},
|
'#/store/audit-policy': { automaticApproval: vue.ref(false), loadAuditPolicy: async () => {} },
|
'#/api': { logoutApi: async (token) => { apiCalls.push(token); if (apiFails === 'pending') return new Promise(() => {}); if (apiFails) throw new Error('offline'); } },
|
}).useAuthStore();
|
const errors = [];
|
const originalError = console.error;
|
console.error = (...args) => errors.push(args);
|
try { await auth.logout(redirect); } finally { console.error = originalError; }
|
assert.deepEqual(apiCalls, ['old-access']);
|
assert.equal(navigations.length, 1);
|
assert.deepEqual(navigations[0], { path: '/auth/login', query: redirect ? { redirect: encodeURIComponent('/analytics?tab=1') } : {} });
|
assert.equal(access.accessToken, null);
|
assert.equal(access.refreshToken, null);
|
assert.equal(access.isAccessChecked, false);
|
assert.equal(access.loginExpired, false);
|
assert.equal(userModule.useUserStore().userInfo, null);
|
assert.equal(company.companyName, '');
|
assert.equal(company.logoUrl, '');
|
assert.equal(company.loginTitle, '');
|
assert.equal(company.loginDescription, '');
|
assert.equal(ui.userInfoCard.show, false);
|
assert.equal(ui.userInfoCard.user, null);
|
assert.deepEqual(ui.contextMenu.items, []);
|
assert.equal(ui.contextMenu.onSelect, null);
|
assert.equal(ui.nextUnreadJumpNonce, 0);
|
assert.equal(rtc.stage, constants.ImRtcCallStage.IDLE);
|
assert.equal(rtc.call, null);
|
assert.equal(rtc.getGroupCall(1), undefined);
|
assert.equal(errors.length, resetFails ? 1 : 0);
|
}
|
|
async function sessionFixture(api) {
|
const app=vue.createApp({});
|
const setup=load('src/packages/stores/src/setup.ts',{'secure-ls':class{},'pinia-plugin-persistedstate':{createPersistedState:()=>()=>{}}});
|
pinia.setActivePinia(await setup.initStores(app,{namespace:'logout-races'}));
|
const accessModule=load('src/packages/stores/src/modules/access.ts'),userModule=load('src/packages/stores/src/modules/user.ts');
|
const access=accessModule.useAccessStore(),user=userModule.useUserStore();const navigations=[];
|
access.setAccessToken('old-access');access.setRefreshToken('old-refresh');
|
const auth=load('src/store/auth.ts',{
|
'vue-router':{useRouter:()=>({currentRoute:vue.ref({fullPath:'/private'}),replace:async to=>navigations.push(to),push:async to=>navigations.push(to)})},
|
'@vben/constants':{LOGIN_PATH:'/auth/login'},'@vben/preferences':{},
|
'@vben/stores':{...setup,...accessModule,...userModule},'ant-design-vue':{notification:{success(){throw Error('Stale success notification')}}},'#/locales':{$t:v=>v},
|
'#/store/audit-policy':{automaticApproval:vue.ref(false),loadAuditPolicy:async()=>{}},
|
'#/api':{logoutApi:async()=>{},...api},
|
}).useAuthStore();
|
return{access,user,auth,navigations};
|
}
|
function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b});return{promise,resolve,reject};}
|
async function pendingLoginCannotRestoreSession(){
|
const pending=deferred();const f=await sessionFixture({loginApi:()=>pending.promise});
|
const login=f.auth.authLogin('username',{});await f.auth.logout(false);
|
pending.resolve({accessToken:'late-access',refreshToken:'late-refresh'});await login;
|
assert.equal(f.access.accessToken,null);assert.equal(f.access.refreshToken,null);assert.equal(f.navigations.length,1);
|
}
|
async function pendingUserInfoCannotRestorePrivateData(){
|
const pending=deferred();const f=await sessionFixture({getAuthPermissionInfoApi:()=>pending.promise});
|
const info=f.auth.fetchUserInfo();const rejected=assert.rejects(info,/登录状态已变化/);
|
await f.auth.logout(false);pending.resolve({user:{nickname:'old-user'},roles:['old-role'],menus:[],permissions:['old-permission']});await rejected;
|
assert.equal(f.user.userInfo,null);assert.deepEqual(f.access.accessCodes,[]);
|
}
|
async function requestRaces(){
|
const f=await sessionFixture({});const pending=deferred();let hooks;
|
class Client{responses=[];requests=[];isRefreshing=false;refreshTokenQueue=[];addRequestInterceptor(x){this.requests.push(x)}addResponseInterceptor(x){this.responses.push(x)}}
|
const presets=load('src/packages/effects/request/src/request-client/preset-interceptors.ts',{
|
'../../../../locales/src':{$t:v=>v},'../../../../utils/src':{isFunction:v=>typeof v==='function'},axios:{isCancel:()=>false},
|
});
|
const request=load('src/api/request.ts',{
|
'@vben/hooks':{isTenantEnable:()=>true,useAppConfig:()=>({apiURL:'/mock'})},
|
'@vben/preferences':{preferences:{app:{enableRefreshToken:true,loginExpiredMode:'page'}}},
|
'@vben/request':{RequestClient:Client,defaultResponseInterceptor:()=>({}),errorMessageResponseInterceptor:()=>({}),authenticateResponseInterceptor:options=>{hooks=options;return presets.authenticateResponseInterceptor(options)}},
|
'@vben/stores':{useAccessStore:()=>f.access},'@vben/utils':{createApiEncrypt:()=>({})},
|
'ant-design-vue':{message:{}},'#/store':{useAuthStore:()=>f.auth},'./core':{refreshTokenApi:()=>pending.promise},
|
});
|
const refresh=hooks.doRefreshToken();const rejected=assert.rejects(refresh,/登录状态已变化/);
|
await f.auth.logout(false);pending.resolve({data:{data:{accessToken:'late-refreshed'}}});await rejected;assert.equal(f.access.accessToken,null);
|
f.access.setAccessToken('new-account-token');f.access.setRefreshToken('new-account-refresh');
|
await hooks.doReAuthenticate({config:{headers:{Authorization:'Bearer old-access'}}});assert.equal(f.access.accessToken,'new-account-token');
|
const cfg={headers:{'tenant-id':7,'visit-tenant-id':9}};
|
await request.baseRequestClient.requests[0].fulfilled(cfg);assert.equal(cfg.headers['tenant-id'],7);assert.equal(cfg.headers['visit-tenant-id'],9);
|
|
const client={isRefreshing:false,refreshTokenQueue:[],request(){throw Error('Failed refresh must not replay queued requests')}};
|
const failed=deferred();let reauthError;const authError={config:{url:'/private',headers:{Authorization:'Bearer old'}},response:{status:401}};
|
const interceptor=presets.authenticateResponseInterceptor({client,doRefreshToken:()=>failed.promise,doReAuthenticate:async e=>{reauthError=e},enableRefreshToken:true,formatToken:t=>t});
|
const first=interceptor.rejected(authError),firstRejected=assert.rejects(first,/refresh failed/);
|
const second=interceptor.rejected({config:{url:'/other',headers:{}},response:{status:401}}),secondRejected=assert.rejects(second,/refresh failed/);
|
const originalError=console.error;console.error=()=>{};
|
try{failed.reject(new Error('refresh failed'));await Promise.all([firstRejected,secondRejected]);}finally{console.error=originalError;}
|
assert.equal(reauthError,authError);assert.equal(client.isRefreshing,false);assert.equal(client.refreshTokenQueue.length,0);
|
}
|
|
(async () => {
|
await scenario(false, false, false);
|
await scenario(true, false, true);
|
await scenario(false, true, false);
|
await scenario(true, true, false);
|
await scenario('pending', false, false);
|
await pendingLoginCannotRestoreSession();
|
await pendingUserInfoCannotRestorePrivateData();
|
await requestRaces();
|
console.log('PASS: pending APIs do not block logout, stale login/user/refresh cannot restore sessions, failed refresh queue rejects; logout redirects and clears session with API success/failure and store reset failures; company and IM stores reset');
|
})();
|