Files
kefu/admin-web/apps/web-antd/tests/authorization-sync.test.mjs
gr 0aeab6c6f5 Merge origin/ai-11-2 into order-9-22
Integrate group password sign-in and live authorization synchronization while
retaining user editing and the restored knowledge review workspace.

Keep local database snapshots and dependency cache trees from the target branch.
Complete English translations and resolve test dependencies through their
owning workspace packages so the new tests work with pnpm's strict layout.
2026-11-04 10:27:26 +08:00

329 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: createRequire(require.resolve('@vben/request'))('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);
});