This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import routesContract from '../contracts/routes.json';
|
||||
import { allRoutes } from '../src/router';
|
||||
import { canAccess, filterMenu, hasRoleAccess } from '../src/store/modules/menu-access';
|
||||
|
||||
describe('router and TDesign menu contracts', () => {
|
||||
it('keeps every PAGE route declared and the static deep-link fallback in the router', () => {
|
||||
expect(routesContract.routes.map((route) => route.pageId).sort()).toEqual(
|
||||
Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
|
||||
);
|
||||
expect(routesContract.routes.find((route) => route.pageId === 'PAGE-15')?.path).toBe('/finance/payments');
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/router/index.ts'), 'utf8')).toContain('createWebHistory');
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/router/modules/system.ts'), 'utf8')).toContain(
|
||||
"{ path: '/:pathMatch(.*)*', redirect: '/result/404' }",
|
||||
);
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/router/modules/result.ts'), 'utf8')).toContain("path: '403'");
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/router/modules/system.ts'), 'utf8')).toContain("path: '/setup'");
|
||||
});
|
||||
|
||||
it('keeps only contracted business routes in the production route tree', () => {
|
||||
const flattenedPaths: string[] = [];
|
||||
const collectPaths = (routes: RouteRecordRaw[], parent = '') => {
|
||||
for (const route of routes) {
|
||||
const path = route.path.startsWith('/') ? route.path : `${parent}/${route.path}`.replace(/\/+/g, '/');
|
||||
flattenedPaths.push(path);
|
||||
if (route.children) collectPaths(route.children, path);
|
||||
}
|
||||
};
|
||||
collectPaths(allRoutes);
|
||||
expect(flattenedPaths).toContain('/projects/');
|
||||
expect(flattenedPaths).not.toContain('/dashboard/base');
|
||||
expect(flattenedPaths).not.toContain('/detail/base');
|
||||
expect(flattenedPaths).not.toContain('/list/base');
|
||||
});
|
||||
|
||||
it('filters menu leaves with the same role and permission rules used by route access', () => {
|
||||
const projectIdentity = { currentRole: 'PROJECT_MANAGER', permissions: ['project:project:view'] };
|
||||
const financeIdentity = {
|
||||
currentRole: 'FINANCE_MANAGER',
|
||||
permissions: ['masterdata:company:view', 'receivable:invoice:view'],
|
||||
};
|
||||
|
||||
const projectRoute = {
|
||||
path: 'project',
|
||||
meta: { roleCodes: ['PROJECT_MANAGER'], permission: 'project:project:view' },
|
||||
} as RouteRecordRaw;
|
||||
const financeRoute = {
|
||||
path: 'finance',
|
||||
meta: { roleCodes: ['FINANCE_MANAGER'], permission: 'workflow:task:view' },
|
||||
} as RouteRecordRaw;
|
||||
const receiptRoute = {
|
||||
path: 'receipts',
|
||||
meta: { roleCodes: ['FINANCE_MANAGER'], permission: ['receivable:receipt:view', 'receivable:invoice:view'] },
|
||||
} as RouteRecordRaw;
|
||||
const accountingRoute = {
|
||||
path: 'accounting',
|
||||
meta: { roleCodes: ['FINANCE_MANAGER'], permission: ['accounting:event:view', 'accounting:voucher:view'] },
|
||||
} as RouteRecordRaw;
|
||||
expect(canAccess(projectRoute, projectIdentity)).toBe(true);
|
||||
expect(canAccess(financeRoute, projectIdentity)).toBe(false);
|
||||
expect(canAccess(receiptRoute, financeIdentity)).toBe(true);
|
||||
expect(canAccess(accountingRoute, financeIdentity)).toBe(false);
|
||||
expect(hasRoleAccess(['PROJECT_MANAGER'], 'SYSTEM_ADMIN')).toBe(true);
|
||||
expect(
|
||||
canAccess(accountingRoute, {
|
||||
currentRole: 'SYSTEM_ADMIN',
|
||||
permissions: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows the isolated system administrator to access every contracted business route', () => {
|
||||
const contractedRoutes: RouteRecordRaw[] = [];
|
||||
const collect = (routes: RouteRecordRaw[]) => {
|
||||
for (const route of routes) {
|
||||
if (typeof route.meta?.pageId === 'string') contractedRoutes.push(route);
|
||||
if (route.children) collect(route.children);
|
||||
}
|
||||
};
|
||||
collect(allRoutes);
|
||||
|
||||
const businessPageIds = new Set(
|
||||
routesContract.routes.filter((route) => route.pageType === 'business').map((route) => route.pageId),
|
||||
);
|
||||
const businessRoutes = contractedRoutes.filter((route) => businessPageIds.has(String(route.meta?.pageId)));
|
||||
|
||||
expect(new Set(businessRoutes.map((route) => route.meta?.pageId))).toEqual(businessPageIds);
|
||||
expect(
|
||||
businessRoutes.filter((route) => !canAccess(route, { currentRole: 'SYSTEM_ADMIN', permissions: [] })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends unauthenticated refreshes to login and denied deep links to the 403 result route', () => {
|
||||
const guardSource = readFileSync(resolve(process.cwd(), 'src/permission.ts'), 'utf8');
|
||||
const userStoreSource = readFileSync(resolve(process.cwd(), 'src/store/modules/user.ts'), 'utf8');
|
||||
expect(userStoreSource).toContain("if (!this.authenticated) throw new Error('Session is not authenticated');");
|
||||
expect(guardSource).toContain("return { path: '/login', query: { redirect: to.fullPath } };");
|
||||
expect(guardSource).toContain("return '/result/403';");
|
||||
expect(guardSource).toContain("to.path === '/setup'");
|
||||
});
|
||||
|
||||
it('redirects each visible menu group to its first permitted child', () => {
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/finance',
|
||||
children: [
|
||||
{ path: 'master-data', meta: { roleCodes: ['FINANCE_MANAGER'], permission: 'masterdata:company:view' } },
|
||||
{ path: 'contracts-costs', meta: { roleCodes: ['PROJECT_MANAGER'], permission: 'contractcost:ledger:view' } },
|
||||
],
|
||||
} as RouteRecordRaw,
|
||||
{
|
||||
path: '/workbench',
|
||||
children: [
|
||||
{ path: 'project', meta: { roleCodes: ['PROJECT_MANAGER'], permission: 'project:project:view' } },
|
||||
{ path: 'finance', meta: { roleCodes: ['FINANCE_MANAGER'], permission: 'workflow:task:view' } },
|
||||
],
|
||||
} as RouteRecordRaw,
|
||||
];
|
||||
const projectMenu = filterMenu(routes, {
|
||||
currentRole: 'PROJECT_MANAGER',
|
||||
permissions: ['contractcost:ledger:view', 'project:project:view'],
|
||||
});
|
||||
const financeGroup = projectMenu.find((route) => route.path === '/finance');
|
||||
expect(financeGroup?.redirect).toBe('/finance/contracts-costs');
|
||||
|
||||
const financeMenu = filterMenu(routes, {
|
||||
currentRole: 'FINANCE_MANAGER',
|
||||
permissions: ['workflow:task:view', 'masterdata:company:view'],
|
||||
});
|
||||
const workbenchGroup = financeMenu.find((route) => route.path === '/workbench');
|
||||
expect(workbenchGroup?.redirect).toBe('/workbench/finance');
|
||||
expect(financeMenu.find((route) => route.path === '/finance')?.redirect).toBe('/finance/master-data');
|
||||
|
||||
const superAdminMenu = filterMenu(routes, {
|
||||
currentRole: 'SYSTEM_ADMIN',
|
||||
permissions: [],
|
||||
});
|
||||
expect(superAdminMenu.find((route) => route.path === '/workbench')?.children).toHaveLength(2);
|
||||
expect(superAdminMenu.find((route) => route.path === '/finance')?.redirect).toBe('/finance/master-data');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user