166 lines
7.6 KiB
TypeScript
166 lines
7.6 KiB
TypeScript
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: 24 }, (_, 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('/dashboard');
|
|
expect(flattenedPaths).toContain('/projects/');
|
|
expect(flattenedPaths).not.toContain('/dashboard/base');
|
|
expect(flattenedPaths).not.toContain('/detail/base');
|
|
expect(flattenedPaths).not.toContain('/list/base');
|
|
});
|
|
|
|
it('places the dashboard first and hides it without its dedicated permission', () => {
|
|
const dashboard = allRoutes.find((route) => route.path === '/dashboard');
|
|
expect(dashboard?.meta?.orderNo).toBe(1);
|
|
expect(dashboard?.children?.[0]?.meta?.permission).toBe('dashboard:overview:view');
|
|
|
|
const withoutDashboard = filterMenu(allRoutes, {
|
|
currentRole: 'FINANCE_MANAGER',
|
|
permissions: ['workflow:task:view'],
|
|
});
|
|
expect(withoutDashboard.some((route) => route.path === '/dashboard')).toBe(false);
|
|
|
|
const withDashboard = filterMenu(allRoutes, {
|
|
currentRole: 'CUSTOM_ROLE',
|
|
permissions: ['dashboard:overview:view'],
|
|
});
|
|
expect(withDashboard[0]?.path).toBe('/dashboard');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|