Files
kaidi/frontend/tests/router-menu-contract.test.ts
T
2026-08-17 14:08:40 +08:00

147 lines
6.8 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: 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');
});
});