Files
kefu/admin-web/apps/web-antd/tests/authorization-sync.test.mjs
T
2026-11-04 10:14:23 +08:00

328 lines
16 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
import vm from 'node:vm';
const require = createRequire(process.env.KEFU_SSO_TYPESCRIPT
? pathToFileURL(resolve(process.env.KEFU_SSO_TYPESCRIPT, 'package.json'))
: new URL('../package.json', import.meta.url));
const ts = require('typescript');
const vue = require('vue');
const pinia = require('pinia');
const routing = require('vue-router');
const workspace = process.env.KEFU_SSO_WORKSPACE || fileURLToPath(new URL('../../../', import.meta.url));
const source = relative => readFile(resolve(workspace, relative), 'utf8');
const staged = relative => readFile(new URL(`../src/${relative}`, import.meta.url), 'utf8');
const normalized = value => JSON.parse(JSON.stringify(value));
const clone = value => Array.isArray(value) ? value.map(clone)
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)])) : value;
function load(code, dependencies = {}, globals = {}) {
const compiled = ts.transpileModule(code.replaceAll('import.meta.hot', 'undefined'), { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
}, reportDiagnostics: true });
assert.deepEqual(compiled.diagnostics, []);
const module = { exports: {} };
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
require(name) { assert.ok(name in dependencies, `Unexpected import ${name}`); return dependencies[name]; },
console, ...globals });
return module.exports;
}
const preferences = { app: { accessMode: 'frontend', loginExpiredMode: 'modal', enableRefreshToken: false },
tabbar: { visitHistory: true, maxCount: 0 } };
const tree = load(await source('packages/@core/base/shared/src/utils/tree.ts'));
const stack = load(await source('packages/@core/base/shared/src/utils/stack.ts'));
const { useAccessStore } = load(await source('packages/stores/src/modules/access.ts'), { pinia });
const { useUserStore } = load(await source('packages/stores/src/modules/user.ts'), { pinia });
const { useTabbarStore, getTabKey } = load(await source('packages/stores/src/modules/tabbar.ts'), {
vue, pinia, '@vben-core/preferences': { preferences },
'@vben-core/shared/utils': { ...stack, startProgress() {}, stopProgress() {}, openWindow() {} },
}, { sessionStorage: {} });
const stores = { useAccessStore, useUserStore, useTabbarStore, getTabKey };
const { useAccess } = load(await source('packages/effects/access/src/use-access.ts'), {
vue, '@vben/preferences': { preferences }, '@vben/stores': stores,
});
const { generateRoutesByFrontend } = load(await source('packages/utils/src/helpers/generate-routes-frontend.ts'), {
'@vben-core/shared/utils': tree,
});
const { generateMenus } = load(await source('packages/utils/src/helpers/generate-menus.ts'), {
'@vben-core/shared/utils': tree,
});
const { generateAccessible } = load(await source('packages/effects/access/src/accessible.ts'), {
vue, '@vben/utils': { ...tree, cloneDeep: clone, generateMenus, generateRoutesByFrontend,
generateRoutesByBackend() { throw new Error('Unexpected backend access mode'); },
isFunction: value => typeof value === 'function', isString: value => typeof value === 'string' },
});
const syncModule = load(await staged('utils/authorization-sync.ts'));
const menuHelpers = load(await source('apps/web-antd/src/router/menu-access.ts'));
const originalRoutes = load(await staged('router/routes/modules/console.ts')).default;
function pageRoutes(input) {
return input.map(route => ({ ...route, meta: { ...route.meta },
...(route.component ? { component: { name: `Test${route.name}`, render: () => null } } : {}),
children: route.children ? pageRoutes(route.children) : undefined }));
}
const accessRoutes = pageRoutes(originalRoutes);
const routes = [
{ name: 'Root', path: '/', component: { render: () => vue.h(routing.RouterView) }, children: [] },
{ name: 'Login', path: '/auth/login', component: { render: () => null } },
{ name: 'FallbackNotFound', path: '/:path(.*)*', component: { render: () => null } },
];
function environment() {
const window = new EventTarget();
const document = new EventTarget();
document.visibilityState = 'visible';
const timers = new Map();
let sequence = 0;
window.setInterval = (callback, delay) => { const id = ++sequence; timers.set(id, { callback, delay }); return id; };
window.clearInterval = id => timers.delete(id);
return { window, document, timers,
tick() { for (const timer of [...timers.values()]) timer.callback(); },
focus() { window.dispatchEvent(new Event('focus')); },
visible(value) { document.visibilityState = value ? 'visible' : 'hidden'; document.dispatchEvent(new Event('visibilitychange')); } };
}
const settle = () => new Promise(resolve => setImmediate(resolve));
const info = (roles = [], userId = '7') => ({ userId, username: `user${userId}`, realName: 'Test', roles });
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
async function harness(initial = []) {
pinia.setActivePinia(pinia.createPinia());
const access = useAccessStore();
const user = useUserStore();
const tabbar = useTabbarStore();
const env = environment();
const router = routing.createRouter({ history: routing.createMemoryHistory(), routes: clone(routes) });
const requests = [];
let answer = info(initial);
let preparations = 0;
const generateAccess = async options => {
preparations += 1;
const generated = await generateAccessible('frontend', options);
return { ...generated, accessibleMenus: menuHelpers.pruneEmptyMenus(generated.accessibleMenus, menuHelpers.parentPaths(options.routes)) };
};
const adapter = load(await staged('router/authorization-sync.ts'), {
vue, 'vue-router': routing, '@vben/stores': stores,
'#/api/core/user': { getUserInfoApi() { requests.push('GET /me'); return Promise.resolve(typeof answer === 'function' ? answer() : clone(answer)); } },
'#/utils/authorization-sync': syncModule, './access': { generateAccess },
'./routes': { accessRoutes, routes: clone(routes) },
}, env);
access.setAccessToken('same-session');
access.setAccessCodes(initial);
user.setUserInfo(info(initial));
const first = await generateAccess({ roles: initial, routes: accessRoutes, router });
access.setAccessMenus(first.accessibleMenus);
access.setAccessRoutes(first.accessibleRoutes);
access.setIsAccessChecked(true);
await router.push('/group/welcome');
const dispose = adapter.installAuthorizationSync(router);
await settle();
return { access, user, tabbar, env, router, requests, adapter, dispose,
preparations: () => preparations,
answer(value) { answer = value; },
async poll(value) { answer = value; env.tick(); await settle(); },
};
}
test('same logged-in token gains/revokes real menus, matcher routes and reactive action rights', async () => {
const h = await harness();
try {
const { hasAccessByCodes } = useAccess();
const createButton = vue.computed(() => hasAccessByCodes(['role:create']));
assert.equal(h.router.hasRoute('RoleManage'), false);
assert.equal(createButton.value, false);
await h.poll(info(['role:read']));
assert.equal(h.router.hasRoute('RoleManage'), true);
assert.ok(h.access.accessMenus.some(menu => menu.children?.some(item => item.path === '/system/roles')));
await h.router.push('/system/roles');
const current = h.router.currentRoute.value;
const record = h.router.getRoutes().find(route => route.name === 'RoleManage');
const component = record.components.default;
const cachedDraft = { component: vue.markRaw({ draft: 'unsaved role form' }), key: '/system/roles', route: vue.markRaw(current) };
h.tabbar.cachedRoutes.set('/system/roles', cachedDraft);
await h.poll(info(['role:read', 'role:create']));
assert.equal(createButton.value, true);
assert.equal(h.router.currentRoute.value, current, 'authorized current route was navigated/replaced');
assert.equal(h.router.getRoutes().find(route => route.name === 'RoleManage').components.default, component);
assert.equal(h.tabbar.cachedRoutes.get('/system/roles').component.draft, 'unsaved role form');
await h.poll(info(['role:read']));
assert.equal(createButton.value, false);
assert.equal(h.router.currentRoute.value, current);
assert.equal(h.access.accessToken, 'same-session');
await h.poll(info([]));
assert.equal(h.router.hasRoute('RoleManage'), false);
assert.equal(h.router.hasRoute('SystemCenter'), false, 'fully revoked branch survived');
assert.equal(h.router.resolve('/system/roles').name, 'FallbackNotFound');
assert.ok(!h.access.accessMenus.some(menu => menu.path === '/system'));
assert.equal(h.router.currentRoute.value.path, '/group/welcome');
assert.equal(h.access.accessToken, 'same-session');
assert.equal(h.tabbar.cachedRoutes.has('/system/roles'), false);
} finally { h.dispose(); }
});
test('grant/revoke siblings preserves an authorized current matcher, component and pinned tab', async () => {
const h = await harness(['user:read']);
try {
await h.router.push('/system/users');
const current = h.router.currentRoute.value;
const components = h.router.getRoutes().find(route => route.name === 'UserManage').components;
h.tabbar.addTab({ ...current, key: '/system/users', meta: { ...current.meta, affixTab: true, keepAlive: true } });
await h.poll(info(['user:read', 'audit:read']));
assert.equal(h.router.hasRoute('AuditLog'), true);
assert.equal(h.router.currentRoute.value, current);
assert.equal(h.router.getRoutes().find(route => route.name === 'UserManage').components, components);
const audit = h.router.resolve('/system/audit');
h.tabbar.addTab({ ...audit, key: '/system/audit', meta: { ...audit.meta, affixTab: true, keepAlive: true } });
h.tabbar.cachedRoutes.set('/system/audit', { key: '/system/audit', route: vue.markRaw(audit), component: vue.markRaw({}) });
await h.poll(info(['user:read']));
assert.equal(h.router.hasRoute('AuditLog'), false);
assert.equal(h.router.currentRoute.value, current);
assert.deepEqual(normalized(h.tabbar.tabs.map(tab => tab.path)), ['/system/users']);
assert.equal(h.tabbar.cachedTabs.has('AuditLog'), false);
assert.equal(h.tabbar.cachedRoutes.has('/system/audit'), false);
assert.equal(h.tabbar.visitHistory.size, 1);
} finally { h.dispose(); }
});
test('equal/reordered permissions refresh profile without rebuilding menus; hidden polls pause and focus resumes', async () => {
const h = await harness(['role:read', 'role:create']);
try {
const before = h.preparations();
await h.poll({ ...info(['role:create', 'role:read', 'role:read']), realName: 'Changed display name' });
assert.equal(h.preparations(), before);
assert.equal(h.user.userInfo.realName, 'Changed display name');
assert.deepEqual([...h.env.timers.values()].map(timer => timer.delay), [3000]);
const requests = h.requests.length;
h.env.visible(false);
h.env.tick();
h.env.focus();
await settle();
assert.equal(h.requests.length, requests);
h.answer(info([]));
h.env.visible(true);
await settle();
assert.equal(h.router.hasRoute('RoleManage'), false);
assert.equal(h.requests.length, requests + 1);
h.answer(info(['role:read']));
h.env.focus();
await settle();
assert.equal(h.router.hasRoute('RoleManage'), true);
h.dispose();
const disposed = h.requests.length;
h.env.tick(); h.env.focus(); h.env.visible(true);
await settle();
assert.equal(h.env.timers.size, 0);
assert.equal(h.requests.length, disposed);
} finally { h.dispose(); }
});
test('concurrent events coalesce; logout disposes; old account response cannot overwrite a new session', async () => {
const h = await harness();
try {
const old = deferred();
h.answer(() => old.promise);
const before = h.requests.length;
h.env.tick(); h.env.focus(); h.env.visible(true); h.env.tick();
assert.equal(h.requests.length, before + 1);
h.access.setAccessToken(null);
assert.equal(h.env.timers.size, 0);
h.user.setUserInfo(info([], '8'));
h.access.setAccessCodes([]);
h.answer(info(['role:read'], '8'));
h.access.setAccessToken('new-session');
await settle();
assert.equal(h.router.hasRoute('RoleManage'), true);
old.resolve(info(['user:read', 'role:create'], '7'));
await settle();
assert.equal(h.access.accessToken, 'new-session');
assert.equal(h.user.userInfo.userId, '8');
assert.deepEqual(normalized(h.access.accessCodes), ['role:read']);
assert.equal(h.router.hasRoute('UserManage'), false);
assert.equal(h.env.timers.size, 1);
} finally { h.dispose(); }
});
test('wrong identity and late preparation are ignored; failed preparation can recover', async () => {
const env = environment();
let session = { token: 'A', userId: '7' };
let answer = info(['role:read'], '8');
const stagedSnapshot = deferred();
let prepares = 0, applied = 0;
let prepare = async () => { prepares += 1; return stagedSnapshot.promise; };
const sync = syncModule.createAuthorizationSync({ ...env, getSession: () => session, getCodes: () => [],
fetch: async () => answer, prepare: value => prepare(value), apply() { applied += 1; } });
sync.start();
await sync.refresh();
assert.equal(prepares, 0);
answer = info(['role:read']);
const pending = sync.refresh();
await Promise.resolve();
session = { token: 'B', userId: '9' };
stagedSnapshot.resolve({});
await pending;
assert.equal(applied, 0);
sync.stop();
session = { token: 'A', userId: '7' };
prepare = async () => { throw new Error('Temporary menu read failure'); };
sync.start();
await sync.refresh();
assert.equal(applied, 0);
prepare = async () => ({});
await sync.refresh();
assert.equal(applied, 1);
sync.stop();
});
test('reinstall is singleton and an old disposer cannot stop its replacement', async () => {
const h = await harness();
const replacement = h.adapter.installAuthorizationSync(h.router);
try {
h.dispose();
await settle();
assert.equal(h.env.timers.size, 1);
h.answer(info(['role:read']));
h.env.focus();
await settle();
assert.equal(h.router.hasRoute('RoleManage'), true);
} finally { replacement(); }
assert.equal(h.env.timers.size, 0);
});
test('actual request auth interceptor ignores an old 401; current 401 retains expiry handling; 403 never retries', async () => {
pinia.setActivePinia(pinia.createPinia());
const access = useAccessStore();
access.setAccessToken('new-session'); access.setIsAccessChecked(true);
const clients = [];
class RequestClient {
constructor() { this.responses = []; clients.push(this); }
addRequestInterceptor() {}
addResponseInterceptor(value) { this.responses.push(value); }
}
const preset = load(await source('packages/effects/request/src/request-client/preset-interceptors.ts'), {
'@vben/locales': { $t: key => key }, '@vben/utils': { isFunction: value => typeof value === 'function' }, axios: require('axios'),
});
let logouts = 0;
load((await staged('api/request.ts')).replaceAll('import.meta.env.PROD', 'false').replaceAll('import.meta.env', '{}'), {
'@vben/hooks': { useAppConfig: () => ({ apiURL: '/api/v2' }) },
'@vben/preferences': { preferences }, '@vben/request': { ...preset, RequestClient }, '@vben/stores': stores,
'ant-design-vue': { message: { error() {} } }, '#/locales/t': { t: key => key },
'#/store': { useAuthStore: () => ({ logout: async () => { logouts += 1; } }) },
'#/utils/authorization-sync': syncModule, './core': { refreshTokenApi: async () => { throw new Error('No refresh token'); } },
'./error-message': { responseErrorMessage: () => 'Error' },
});
const authentication = clients[0].responses[1];
const error = (status, token) => ({ config: { headers: { Authorization: `Bearer ${token}` } }, response: { status } });
await assert.rejects(authentication.rejected(error(401, 'old-session')));
assert.equal(access.accessToken, 'new-session');
assert.equal(access.loginExpired, false);
await assert.rejects(authentication.rejected(error(403, 'new-session')));
assert.equal(access.accessToken, 'new-session');
assert.equal(logouts, 0);
await assert.rejects(authentication.rejected(error(401, 'new-session')));
assert.equal(access.accessToken, null);
assert.equal(access.loginExpired, true);
});