328 lines
16 KiB
JavaScript
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);
|
|
});
|