This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ARCHIVE_API_BASE,
|
||||
archiveErrorText,
|
||||
normalizeArchiveFileDetail,
|
||||
normalizeArchivePackage,
|
||||
} from '../src/api/archive';
|
||||
|
||||
describe('archive R4 frontend contracts', () => {
|
||||
it('uses the documented plural archive API prefix', () => {
|
||||
expect(ARCHIVE_API_BASE).toBe('/archives');
|
||||
});
|
||||
|
||||
it('normalizes nullable archive response collections and action allowlists', () => {
|
||||
const packageView = normalizeArchivePackage({
|
||||
allowedActions: null,
|
||||
items: [{ allowedActions: null }],
|
||||
objectSnapshots: null,
|
||||
actions: undefined,
|
||||
} as any);
|
||||
const detail = normalizeArchiveFileDetail({
|
||||
file: { allowedActions: null },
|
||||
versions: null,
|
||||
borrows: [{ allowedActions: undefined }],
|
||||
} as any);
|
||||
|
||||
expect(packageView).toMatchObject({
|
||||
allowedActions: [],
|
||||
items: [{ allowedActions: [] }],
|
||||
objectSnapshots: [],
|
||||
actions: [],
|
||||
});
|
||||
expect(detail).toMatchObject({ file: { allowedActions: [] }, versions: [], borrows: [{ allowedActions: [] }] });
|
||||
});
|
||||
|
||||
it('renders actionable error classes without dropping server field errors', () => {
|
||||
expect(archiveErrorText({ status: 401 }, '失败')).toContain('登录会话已失效');
|
||||
expect(archiveErrorText({ status: 403 }, '失败')).toContain('没有访问');
|
||||
expect(archiveErrorText({ status: 404 }, '失败')).toContain('不存在');
|
||||
expect(archiveErrorText({ status: 409 }, '失败')).toContain('数据已发生变化');
|
||||
expect(archiveErrorText({ status: 422, fieldErrors: { dueAt: '日期无效' } }, '失败')).toContain('日期无效');
|
||||
expect(archiveErrorText({ status: 503 }, '失败')).toContain('服务处理失败');
|
||||
});
|
||||
|
||||
it('keeps downloaded blob URLs alive until the browser consumes them', () => {
|
||||
const page = readFileSync(new URL('../src/pages/archives/ArchiveFilesPage.vue', import.meta.url), 'utf8');
|
||||
expect(page).toContain('window.setTimeout(() => URL.revokeObjectURL(url), 60_000)');
|
||||
});
|
||||
|
||||
it('exposes a recoverable workflow for quarantined archive files', () => {
|
||||
const api = readFileSync(new URL('../src/api/archive.ts', import.meta.url), 'utf8');
|
||||
const page = readFileSync(new URL('../src/pages/archives/ArchiveFilesPage.vue', import.meta.url), 'utf8');
|
||||
const rescanPath = '`$' + '{ARCHIVE_API_BASE}/files/$' + '{publicId}/rescan`';
|
||||
expect(api).toContain('export function rescanArchiveFile');
|
||||
expect(api).toContain(rescanPath);
|
||||
expect(api).toContain("commandHeaders('archive-file-rescan'");
|
||||
expect(page).toContain("row.allowedActions.includes('RESCAN')");
|
||||
expect(page).toContain('@click="rescanFile(row)"');
|
||||
expect(page).toContain("fileFilters.scanStatus = 'QUARANTINED'");
|
||||
expect(page).toContain('文件已通过安全检查并恢复可用');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import ts from 'typescript';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import componentsContract from '../contracts/components.json';
|
||||
import operationsContract from '../contracts/operations.json';
|
||||
import routesContract from '../contracts/routes.json';
|
||||
import { parseWorkbenchTarget } from '../src/pages/workbench/workbench-navigation';
|
||||
import { allRoutes } from '../src/router';
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
interface ApiOperation {
|
||||
module: string;
|
||||
export: string;
|
||||
args?: unknown[];
|
||||
}
|
||||
interface ContractOperation {
|
||||
operationId: string;
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
pages: string[];
|
||||
api?: ApiOperation;
|
||||
}
|
||||
interface CapturedOperation {
|
||||
module: string;
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const apiRoot = resolve(process.cwd(), 'src/api');
|
||||
const operations = operationsContract.operations as ContractOperation[];
|
||||
|
||||
function propertyName(name: ts.PropertyName | ts.BindingName): string | undefined {
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function evaluatePath(expression: ts.Expression, constants = new Map<string, string>()): string | undefined {
|
||||
if (ts.isStringLiteralLike(expression)) return expression.text;
|
||||
if (ts.isIdentifier(expression)) return constants.get(expression.text);
|
||||
if (ts.isTemplateExpression(expression)) {
|
||||
let result = expression.head.text;
|
||||
for (const span of expression.templateSpans)
|
||||
result += `${evaluatePath(span.expression, constants) || '{param}'}${span.literal.text}`;
|
||||
return result;
|
||||
}
|
||||
if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
||||
const left = evaluatePath(expression.left, constants);
|
||||
const right = evaluatePath(expression.right, constants);
|
||||
return left !== undefined && right !== undefined ? left + right : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const withoutQuery = path.split('?')[0];
|
||||
const noPrefix = withoutQuery.replace(/^\/api\/v1/, '') || '/';
|
||||
return `/api/v1${noPrefix}`
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/api\/v1\/archive(?=\/|$)/, '/api/v1/archives')
|
||||
.replace(/\{[^}]+\}/g, '{param}');
|
||||
}
|
||||
|
||||
function pathMatches(left: string, right: string): boolean {
|
||||
const leftParts = normalizePath(left).split('/');
|
||||
const rightParts = normalizePath(right).split('/');
|
||||
if (leftParts.length !== rightParts.length) return false;
|
||||
return leftParts.every(
|
||||
(part, index) => part === rightParts[index] || part === '{param}' || rightParts[index] === '{param}',
|
||||
);
|
||||
}
|
||||
|
||||
function urlProperty(object: ts.ObjectLiteralExpression): ts.Expression | undefined {
|
||||
for (const member of object.properties) {
|
||||
if (!ts.isPropertyAssignment(member)) continue;
|
||||
if (propertyName(member.name) === 'url') return member.initializer;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractApiOperations(module: string): CapturedOperation[] {
|
||||
const filePath = resolve(apiRoot, `${module}.ts`);
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const result: CapturedOperation[] = [];
|
||||
const constants = new Map<string, string>();
|
||||
const collectConstants = (node: ts.Node) => {
|
||||
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
||||
const value = evaluatePath(node.initializer, constants);
|
||||
if (value !== undefined) constants.set(node.name.text, value);
|
||||
}
|
||||
ts.forEachChild(node, collectConstants);
|
||||
};
|
||||
collectConstants(sourceFile);
|
||||
const add = (method: HttpMethod, expression: ts.Expression | undefined) => {
|
||||
const path = expression && evaluatePath(expression, constants);
|
||||
if (path) result.push({ module, method, path: normalizePath(path) });
|
||||
};
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = node.expression;
|
||||
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
|
||||
const method = callee.name.text.toUpperCase() as HttpMethod;
|
||||
if (['GET', 'POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
|
||||
const first = node.arguments[0];
|
||||
if (first && ts.isObjectLiteralExpression(first)) add(method, urlProperty(first));
|
||||
}
|
||||
}
|
||||
if (ts.isIdentifier(callee) && ['page', 'getPage'].includes(callee.text)) add('GET', node.arguments[0]);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return result;
|
||||
}
|
||||
|
||||
function exportedFunctions(module: string): Set<string> {
|
||||
const source = readFileSync(resolve(apiRoot, `${module}.ts`), 'utf8');
|
||||
const names = new Set<string>();
|
||||
const re = /export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g;
|
||||
for (const match of source.matchAll(re)) names.add(match[1]);
|
||||
return names;
|
||||
}
|
||||
|
||||
function joinRoutePath(parent: string, child: string): string {
|
||||
if (child.startsWith('/')) return child;
|
||||
if (!child) return parent || '/';
|
||||
return `${parent}/${child}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
function runtimeContractRoutes() {
|
||||
const result = new Map<string, { path: string; meta: Record<string, unknown> }>();
|
||||
const walk = (routes: typeof allRoutes, parentPath = '') => {
|
||||
for (const route of routes) {
|
||||
const path = joinRoutePath(parentPath, route.path);
|
||||
if (typeof route.meta?.pageId === 'string') {
|
||||
result.set(route.meta.pageId, { path, meta: route.meta as Record<string, unknown> });
|
||||
}
|
||||
if (route.children) walk(route.children, path);
|
||||
}
|
||||
};
|
||||
walk(allRoutes);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('frontend machine contracts', () => {
|
||||
it('does not append undocumented cache-busting query parameters by default', () => {
|
||||
const requestSource = readFileSync(resolve(process.cwd(), 'src/utils/request/index.ts'), 'utf8');
|
||||
expect(requestSource).toContain('joinTime = false');
|
||||
expect(requestSource).toMatch(/joinTime:\s*false/);
|
||||
});
|
||||
|
||||
it('consumes the stable governance list DTO without database-key normalization', () => {
|
||||
const governanceApi = readFileSync(resolve(apiRoot, 'governance.ts'), 'utf8');
|
||||
const settingsPage = readFileSync(resolve(process.cwd(), 'src/pages/governance/SystemSettingsPage.vue'), 'utf8');
|
||||
expect(governanceApi).toContain('export interface GovernanceRow');
|
||||
expect(governanceApi).toContain('resource: GovernanceResource');
|
||||
expect(governanceApi).toContain('roleCodes?: string[]');
|
||||
expect(governanceApi).not.toContain('Record<string, unknown> &');
|
||||
expect(settingsPage).not.toContain('function camelKey');
|
||||
expect(settingsPage).not.toContain('normalizeRows(result.items)');
|
||||
});
|
||||
|
||||
it('uses the canonical JSON POST contract for payment exports', () => {
|
||||
const paymentApi = readFileSync(resolve(apiRoot, 'payment.ts'), 'utf8');
|
||||
expect(paymentApi).toContain('request.post<AxiosResponse<Blob>>');
|
||||
expect(paymentApi).toContain("url: '/payments/exports'");
|
||||
expect(paymentApi).toContain('data: params');
|
||||
expect(paymentApi).toContain("responseType: 'blob'");
|
||||
});
|
||||
|
||||
it('declares every PAGE-01 through PAGE-23 exactly once', () => {
|
||||
const expected = Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
|
||||
const routePageIds = routesContract.routes.map((route) => route.pageId);
|
||||
const componentPageIds = componentsContract.pages.map((page) => page.pageId);
|
||||
expect([...routePageIds].sort()).toEqual([...expected].sort());
|
||||
expect(new Set(routePageIds).size).toBe(23);
|
||||
expect([...componentPageIds].sort()).toEqual([...expected].sort());
|
||||
});
|
||||
|
||||
it('points every page contract at a real non-placeholder component', () => {
|
||||
for (const route of routesContract.routes) {
|
||||
const componentPath = resolve(process.cwd(), route.component);
|
||||
expect(existsSync(componentPath), `${route.pageId}: ${route.component}`).toBe(true);
|
||||
const source = readFileSync(componentPath, 'utf8');
|
||||
expect(source).not.toContain('PagePlaceholder');
|
||||
expect(source).not.toContain('业务数据接口正在按总方案逐页接入');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps route metadata complete and machine-readable', () => {
|
||||
for (const route of routesContract.routes) {
|
||||
expect(route.pageId).toMatch(/^PAGE-\d{2}$/);
|
||||
expect(route.path).toMatch(/^\//);
|
||||
expect(route.pageType).toMatch(/^(system|business)$/);
|
||||
expect(route.pageTemplate).toBeTruthy();
|
||||
expect(typeof route.requiresAuth).toBe('boolean');
|
||||
expect(Array.isArray(route.roles)).toBe(true);
|
||||
expect('permission' in route).toBe(true);
|
||||
expect(Array.isArray(route.breadcrumb)).toBe(true);
|
||||
expect(route.description).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps runtime routes aligned with the route contract', () => {
|
||||
const runtimeRoutes = runtimeContractRoutes();
|
||||
expect([...runtimeRoutes.keys()].sort()).toEqual(routesContract.routes.map((route) => route.pageId).sort());
|
||||
for (const route of routesContract.routes) {
|
||||
const runtime = runtimeRoutes.get(route.pageId);
|
||||
expect(runtime?.path, `${route.pageId}: path`).toBe(route.path);
|
||||
expect(runtime?.meta.pageType, `${route.pageId}: pageType`).toBe(route.pageType);
|
||||
expect(runtime?.meta.pageTemplate, `${route.pageId}: pageTemplate`).toBe(route.pageTemplate);
|
||||
expect(runtime?.meta.requiresAuth, `${route.pageId}: requiresAuth`).toBe(route.requiresAuth);
|
||||
expect(runtime?.meta.roleCodes, `${route.pageId}: roles`).toEqual(route.roles);
|
||||
expect(runtime?.meta.permission, `${route.pageId}: permission`).toEqual(route.permission);
|
||||
expect(runtime?.meta.description, `${route.pageId}: description`).toBe(route.description);
|
||||
expect(runtime?.meta.breadcrumb, `${route.pageId}: breadcrumb`).toEqual(route.breadcrumb);
|
||||
expect(runtime?.meta.componentPath, `${route.pageId}: component`).toBe(route.component);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires unique operations with valid page references and API wrappers', () => {
|
||||
const pageIds = new Set(routesContract.routes.map((route) => route.pageId));
|
||||
expect(new Set(operations.map((operation) => operation.operationId)).size).toBe(operations.length);
|
||||
for (const operation of operations) {
|
||||
expect(operation.path).toMatch(/^\/api\/v1\//);
|
||||
expect(operation.pages.length).toBeGreaterThan(0);
|
||||
for (const page of operation.pages) expect(pageIds.has(page), `${operation.operationId}: ${page}`).toBe(true);
|
||||
expect(operation.api, `${operation.operationId} must identify its wrapper`).toBeTruthy();
|
||||
if (operation.api) {
|
||||
expect(existsSync(resolve(apiRoot, `${operation.api.module}.ts`))).toBe(true);
|
||||
expect(
|
||||
exportedFunctions(operation.api.module).has(operation.api.export),
|
||||
`${operation.operationId}: ${operation.api.export}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('matches every business API wrapper path and method in both directions', () => {
|
||||
const observed: CapturedOperation[] = [];
|
||||
for (const entry of readdirSync(apiRoot)) {
|
||||
if (!entry.endsWith('.ts')) continue;
|
||||
const module = entry.slice(0, -3);
|
||||
observed.push(...extractApiOperations(module));
|
||||
}
|
||||
const declared = operations.map((operation) => ({ ...operation, normalizedPath: normalizePath(operation.path) }));
|
||||
for (const operation of declared) {
|
||||
const match = observed.some(
|
||||
(item) => item.method === operation.method && pathMatches(item.path, operation.normalizedPath),
|
||||
);
|
||||
expect(match, `${operation.operationId}: ${operation.method} ${operation.path}`).toBe(true);
|
||||
}
|
||||
for (const item of observed) {
|
||||
const match = declared.some(
|
||||
(operation) => operation.method === item.method && pathMatches(item.path, operation.normalizedPath),
|
||||
);
|
||||
expect(match, `unregistered API wrapper: ${item.module} ${item.method} ${item.path}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('iam public-entry safeguards', () => {
|
||||
const loginFormSource = readFileSync(resolve(process.cwd(), 'src/pages/login/components/Login.vue'), 'utf8');
|
||||
const roleSelectSource = readFileSync(resolve(process.cwd(), 'src/pages/role-select/index.vue'), 'utf8');
|
||||
const permissionSource = readFileSync(resolve(process.cwd(), 'src/permission.ts'), 'utf8');
|
||||
|
||||
it('always sends a newly authenticated user to PAGE-02', () => {
|
||||
expect(loginFormSource).toContain("await router.replace('/role-select')");
|
||||
expect(loginFormSource).not.toContain('route.query.redirect');
|
||||
});
|
||||
|
||||
it('blocks an invalid mixed administrator and business identity configuration', () => {
|
||||
expect(roleSelectSource).toContain("role.code === 'SYSTEM_ADMIN'");
|
||||
expect(roleSelectSource).toContain("'PROJECT_MANAGER', 'FINANCE_MANAGER', 'ARCHIVE_MANAGER'");
|
||||
expect(roleSelectSource).toContain('账号身份配置冲突,请联系系统管理员处理。');
|
||||
});
|
||||
|
||||
it('clears identity navigation once when a session-expired event is raised', () => {
|
||||
expect(permissionSource).toContain('let handlingSessionExpiry = false;');
|
||||
expect(permissionSource).toContain('if (handlingSessionExpiry) return;');
|
||||
expect(permissionSource).toContain('getPermissionStore().restoreRoutes();');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workbench target allowlist', () => {
|
||||
it('keeps approved routes and query keys', () => {
|
||||
expect(parseWorkbenchTarget('/finance/payments?tab=result&riskCode=BLOCK')).toEqual({
|
||||
path: '/finance/payments',
|
||||
query: { tab: 'result', riskCode: 'BLOCK' },
|
||||
});
|
||||
expect(parseWorkbenchTarget('/projects/00000000000000000000000001')).toEqual({
|
||||
path: '/projects/00000000000000000000000001',
|
||||
});
|
||||
expect(parseWorkbenchTarget('/finance/payments?projectId=00000000000000000000000001&view=fund-ledger')).toEqual({
|
||||
path: '/finance/payments',
|
||||
query: { projectId: '00000000000000000000000001', view: 'fund-ledger' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unknown query keys and rejects unknown paths', () => {
|
||||
expect(parseWorkbenchTarget('/tasks?view=todo&token=SECRET')).toEqual({
|
||||
path: '/tasks',
|
||||
query: { view: 'todo' },
|
||||
});
|
||||
expect(parseWorkbenchTarget('https://example.com')).toBeNull();
|
||||
expect(parseWorkbenchTarget('/projects/not-an-id')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the approved task filter snapshot', () => {
|
||||
expect(parseWorkbenchTarget('/tasks?view=TODO&formType=OA-06&status=PENDING&page=2&size=50')).toEqual({
|
||||
path: '/tasks',
|
||||
query: { view: 'TODO', formType: 'OA-06', status: 'PENDING', page: '2', size: '50' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps PAGE-18 workbench targets inside the frozen query contract', () => {
|
||||
expect(parseWorkbenchTarget('/archives/files?resource=borrows&scanStatus=AVAILABLE')).toEqual({
|
||||
path: '/archives/files',
|
||||
query: { resource: 'borrows', scanStatus: 'AVAILABLE' },
|
||||
});
|
||||
expect(parseWorkbenchTarget('/archives/files?archiveStatus=FROZEN')).toEqual({
|
||||
path: '/archives/files',
|
||||
query: { archiveStatus: 'FROZEN' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps scoped project and archive workbench filters', () => {
|
||||
expect(parseWorkbenchTarget('/forms?status=RETURNED&createdByMe=true')).toEqual({
|
||||
path: '/forms',
|
||||
query: { status: 'RETURNED', createdByMe: 'true' },
|
||||
});
|
||||
expect(parseWorkbenchTarget('/archives/projects?view=prepare&completeness=INCOMPLETE')).toEqual({
|
||||
path: '/archives/projects',
|
||||
query: { view: 'prepare', completeness: 'INCOMPLETE' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the approved non-sensitive PAGE-19 filter snapshot', () => {
|
||||
expect(
|
||||
parseWorkbenchTarget(
|
||||
'/reports?reportCode=payment-progress&companyId=C&projectId=P&dateFrom=2026-08-01&dateTo=2026-08-31&page=2&size=50&token=SECRET',
|
||||
),
|
||||
).toEqual({
|
||||
path: '/reports',
|
||||
query: {
|
||||
reportCode: 'payment-progress',
|
||||
companyId: 'C',
|
||||
projectId: 'P',
|
||||
dateFrom: '2026-08-01',
|
||||
dateTo: '2026-08-31',
|
||||
page: '2',
|
||||
size: '50',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('page-19 report snapshot contracts', () => {
|
||||
const reportsPageSource = readFileSync(resolve(process.cwd(), 'src/pages/reports/ReportsPage.vue'), 'utf8');
|
||||
const reportsApiSource = readFileSync(resolve(process.cwd(), 'src/api/reports.ts'), 'utf8');
|
||||
|
||||
it('uses the applied date filters for drilldown and freezes them for export confirmation', () => {
|
||||
expect(reportsPageSource).toContain('getReportDrilldown(snapshot.reportCode, rowId, snapshot.filters)');
|
||||
expect(reportsPageSource).toContain('...snapshot.filters,');
|
||||
expect(reportsPageSource).toContain(
|
||||
'const exportDateRange = computed(() => formatDateRange(exportSnapshot.value?.filters));',
|
||||
);
|
||||
expect(reportsPageSource).toContain(
|
||||
'<t-descriptions-item :label="exportDateBasisLabel">{{ exportDateRange }}</t-descriptions-item>',
|
||||
);
|
||||
expect(reportsPageSource).toContain("'receipt-invoice': '收款/开票业务日期'");
|
||||
expect(reportsPageSource).toContain("'workflow-duration': '任务到达日期'");
|
||||
});
|
||||
|
||||
it('keeps export confirmation bound to the prepared applied snapshot', () => {
|
||||
expect(reportsApiSource).toContain('export interface ReportExportSnapshot');
|
||||
expect(reportsPageSource).toContain('const exportSnapshot = ref<ReportExportSnapshot | null>(null);');
|
||||
expect(reportsPageSource).toContain('confirmReportExport(snapshot.reportCode, prepared.exportId)');
|
||||
expect(reportsPageSource).toContain("throw new Error('导出筛选快照已变化,请重新预检')");
|
||||
});
|
||||
|
||||
it('blocks a single cross-currency monetary aggregate until the API returns currency groups', () => {
|
||||
expect(reportsPageSource).toContain('const moneySummaryReports = new Set<ReportCode>([');
|
||||
expect(reportsPageSource).toContain("hasUnsafeCurrencyAggregate.value ? '未按币种分组,已阻断合计'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('page-08 project detail safeguards', () => {
|
||||
const projectPageSource = readFileSync(resolve(process.cwd(), 'src/pages/projects/ProjectDetailPage.vue'), 'utf8');
|
||||
const projectApiSource = readFileSync(resolve(process.cwd(), 'src/api/project.ts'), 'utf8');
|
||||
|
||||
it('does not request project data after the active identity loses view permission', () => {
|
||||
expect(projectPageSource).toContain("if (!userStore.hasPermission('project:project:view'))");
|
||||
expect(projectPageSource).toContain("errorMessage.value = '当前身份没有查看项目详情的权限'");
|
||||
expect(projectPageSource).toContain('projectRequestSequence += 1;');
|
||||
expect(projectPageSource).toContain('resetDrilldowns();');
|
||||
});
|
||||
|
||||
it('normalizes optional arrays and malformed paged subresource responses', () => {
|
||||
expect(projectPageSource).toContain('allowedActions: Array.isArray(nextProject.allowedActions)');
|
||||
expect(projectPageSource).toContain('riskFlags: Array.isArray(nextProject.riskFlags)');
|
||||
expect(projectPageSource).toContain('timeline: Array.isArray(nextProject.timeline)');
|
||||
expect(projectApiSource).toContain('Array.isArray(envelope?.data) ? envelope.data : []');
|
||||
expect(projectApiSource).toContain('totalElements: 0, totalPages: 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('page-09 to page-11 source workflow response safeguards', () => {
|
||||
const sourceApiSource = readFileSync(resolve(process.cwd(), 'src/api/source.ts'), 'utf8');
|
||||
|
||||
it('normalizes collection and command responses before a TDesign page renders them', () => {
|
||||
expect(sourceApiSource).toContain('(Array.isArray(envelope?.data) ? envelope.data : []).map(normalizeSummary)');
|
||||
expect(sourceApiSource).toContain('(Array.isArray(envelope?.data) ? envelope.data : []).map(normalizeTask)');
|
||||
expect(sourceApiSource).toContain('allowedActions: Array.isArray(summary?.allowedActions)');
|
||||
expect(sourceApiSource).toContain('allowedActions: Array.isArray(task?.allowedActions)');
|
||||
expect(sourceApiSource).toContain('.then(normalizeTaskDetail)');
|
||||
expect(sourceApiSource).toContain('.then(normalizeDetail)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const sourceApi = readFileSync(resolve(process.cwd(), 'src/api/source.ts'), 'utf8');
|
||||
const projectApi = readFileSync(resolve(process.cwd(), 'src/api/project.ts'), 'utf8');
|
||||
const formCenter = readFileSync(resolve(process.cwd(), 'src/pages/forms/FormCenterPage.vue'), 'utf8');
|
||||
const formEditor = readFileSync(resolve(process.cwd(), 'src/pages/forms/FormEditorPage.vue'), 'utf8');
|
||||
const taskCenter = readFileSync(resolve(process.cwd(), 'src/pages/tasks/TaskCenterPage.vue'), 'utf8');
|
||||
const projectDetail = readFileSync(resolve(process.cwd(), 'src/pages/projects/ProjectDetailPage.vue'), 'utf8');
|
||||
const accountingPage = readFileSync(resolve(process.cwd(), 'src/pages/finance/AccountingPage.vue'), 'utf8');
|
||||
const projectArchives = readFileSync(resolve(process.cwd(), 'src/pages/archives/ProjectArchivesPage.vue'), 'utf8');
|
||||
const archiveFiles = readFileSync(resolve(process.cwd(), 'src/pages/archives/ArchiveFilesPage.vue'), 'utf8');
|
||||
const projectSubresourcePanel = readFileSync(
|
||||
resolve(process.cwd(), 'src/components/business/project/ProjectSubresourcePanel.vue'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('pAGE-07 through PAGE-11 command controls', () => {
|
||||
it('adds command idempotency headers to source and project writes', () => {
|
||||
expect(sourceApi).toContain("commandHeaders('source-form-create', idempotencyKey)");
|
||||
expect(sourceApi).toContain("commandHeaders('source-form-submit', idempotencyKey)");
|
||||
expect(sourceApi).toContain('commandHeaders(`workflow-task-' + '$' + '{action}`, idempotencyKey)');
|
||||
expect(sourceApi).toContain("commandHeaders('workflow-instance-withdraw', idempotencyKey)");
|
||||
expect(sourceApi).toContain("commandHeaders('workflow-instance-void', idempotencyKey)");
|
||||
expect(sourceApi).toContain("commandHeaders('source-import-create', idempotencyKey)");
|
||||
expect(sourceApi).toContain("commandHeaders('source-import-confirm', idempotencyKey)");
|
||||
expect(projectApi).toContain("commandHeaders('project-risk-create', idempotencyKey)");
|
||||
expect(projectApi).toContain("commandHeaders('project-risk-resolve', idempotencyKey)");
|
||||
});
|
||||
|
||||
it('gates mutations with server allowedActions and re-reads server state', () => {
|
||||
expect(formCenter).toContain("!row.allowedActions.includes('VALIDATE')");
|
||||
expect(formCenter).toContain('await loadForms();');
|
||||
expect(formEditor).toContain("detail.value.summary.allowedActions.includes('SUBMIT')");
|
||||
expect(formEditor).toContain('await loadDetail();');
|
||||
expect(taskCenter).toContain('if (!canRunAction(task, nextAction)) return;');
|
||||
expect(taskCenter).toContain('await getWorkflowTask(task.publicId);');
|
||||
expect(taskCenter).toContain('task.form.allowedActions.includes(code)');
|
||||
expect(taskCenter).toContain('await withdrawSourceForm(');
|
||||
expect(taskCenter).toContain('await voidSourceForm(');
|
||||
expect(taskCenter).toContain('const detail = await getWorkflowTask(task.publicId);');
|
||||
expect(taskCenter).not.toContain("opinion: '审批通过'");
|
||||
expect(projectDetail).toContain("!project.value.allowedActions.includes('CREATE_RISK_FLAG')");
|
||||
expect(projectDetail).toContain('await loadProject();');
|
||||
});
|
||||
|
||||
it('uses active source template metadata and TDesign controls for projection fields', () => {
|
||||
expect(sourceApi).toContain('suppliers: Array.isArray(references?.suppliers)');
|
||||
expect(formEditor).toContain('const active = (await getSourceTemplates()).find');
|
||||
expect(formEditor).not.toContain('getSourceTemplate(formType.value, 1)');
|
||||
expect(formEditor).toContain("field.code.toLowerCase().includes('supplier')");
|
||||
expect(formEditor).toContain("column.dataType === 'ENUM'");
|
||||
expect(formEditor).toContain("column.dataType === 'DATE'");
|
||||
expect(formEditor).toContain('detail.value?.fields || template.value?.fields');
|
||||
expect(formEditor).toContain('<t-select');
|
||||
expect(formEditor).toContain('<t-date-picker');
|
||||
});
|
||||
|
||||
it('keeps project drill-down rows traceable to their unique source', () => {
|
||||
expect(projectSubresourcePanel).toContain('#businessNo="{ row }"');
|
||||
expect(projectSubresourcePanel).toContain("$emit('open-row', row)");
|
||||
expect(projectDetail).toContain('@open-row="openDrillRow(tab.value, $event)"');
|
||||
expect(projectDetail).toContain("name: 'FormEditor'");
|
||||
expect(projectDetail).toContain('query: { from: route.fullPath }');
|
||||
expect(projectDetail).toContain("openLinkedPage('/finance/payments'");
|
||||
expect(projectDetail).toContain("openLinkedPage('/finance/accounting'");
|
||||
expect(projectDetail).toContain("openLinkedPage('/archives/files'");
|
||||
expect(formEditor).toContain("from.startsWith('/projects/')");
|
||||
});
|
||||
|
||||
it('keeps project accounting and archive drill-down bidirectional across lifecycle states', () => {
|
||||
expect(projectDetail).toContain("voucherStatus: textValue(row.voucherStatus, '')");
|
||||
expect(projectDetail).toContain("sortAt: textValue(isVoucher ? row.updatedAt : row.createdAt, '')");
|
||||
expect(projectDetail).toContain('return rightTime - leftTime;');
|
||||
expect(projectDetail).toContain('from: route.fullPath');
|
||||
expect(accountingPage).toContain("name: 'ProjectDetail'");
|
||||
expect(accountingPage).toContain('function openEventSource(event: AccountingEvent)');
|
||||
expect(accountingPage).toContain("route.name !== 'Accounting'");
|
||||
expect(formCenter).toContain("name: 'ProjectDetail'");
|
||||
expect(projectArchives).toContain('function openSnapshot(snapshot: ArchivePackageObjectSnapshot)');
|
||||
expect(projectArchives).toContain("ACCOUNTING_VOUCHER: { path: '/finance/accounting'");
|
||||
expect(archiveFiles).toContain("name: 'ProjectDetail'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const paymentApi = readFileSync(resolve(process.cwd(), 'src/api/payment.ts'), 'utf8');
|
||||
const paymentPage = readFileSync(resolve(process.cwd(), 'src/pages/finance/PaymentsPage.vue'), 'utf8');
|
||||
|
||||
describe('page-15 payment control exceptions', () => {
|
||||
it('models warnings and normalizes server-controlled actions', () => {
|
||||
expect(paymentApi).toContain("result: 'PASS' | 'WARNING' | 'BLOCK'");
|
||||
expect(paymentApi).toContain("| 'REQUEST_CONTROL_EXCEPTION'");
|
||||
expect(paymentApi).toContain('controlExceptions: (Array.isArray(detail?.controlExceptions)');
|
||||
expect(paymentApi).toContain('attachmentExceptions: (Array.isArray(detail?.attachmentExceptions)');
|
||||
expect(paymentApi).toContain('allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : []');
|
||||
});
|
||||
|
||||
it('exposes request and independent review commands with idempotency headers', () => {
|
||||
expect(paymentApi).toContain('`/payments/' + '$' + '{paymentPublicId}/control-exceptions`');
|
||||
expect(paymentApi).toContain(
|
||||
'`/payments/' + '$' + '{paymentPublicId}/control-exceptions/' + '$' + '{exceptionPublicId}/review`',
|
||||
);
|
||||
expect(paymentApi).toContain("commandHeaders('payment-control-exception-request', idempotencyKey)");
|
||||
expect(paymentApi).toContain("commandHeaders('payment-control-exception-review', idempotencyKey)");
|
||||
});
|
||||
|
||||
it('gates request and review actions with backend allowedActions and optimistic versions', () => {
|
||||
expect(paymentPage).toContain("detail.value?.payment.allowedActions.includes('REQUEST_CONTROL_EXCEPTION')");
|
||||
expect(paymentPage).toContain("row.allowedActions.includes('REVIEW')");
|
||||
expect(paymentPage).toContain('当前附件不涉及申请不可复核,请刷新后重试。');
|
||||
expect(paymentPage).toContain('paymentVersion: detail.value.payment.version');
|
||||
expect(paymentPage).toContain('version: row.version');
|
||||
expect(paymentPage).toContain('await loadDetail(paymentId, true)');
|
||||
});
|
||||
|
||||
it('uses TDesign dialogs, tables, select, date picker and text areas for the workflow', () => {
|
||||
expect(paymentPage).toContain('aria-label="付款检查例外"');
|
||||
expect(paymentPage).toContain('v-model:visible="controlExceptionRequestVisible"');
|
||||
expect(paymentPage).toContain('v-model:visible="controlExceptionReviewVisible"');
|
||||
expect(paymentPage).toContain('<t-select v-model="controlExceptionRequestData.controlCode"');
|
||||
expect(paymentPage).toContain('<t-date-picker');
|
||||
expect(paymentPage).toContain('v-model="controlExceptionReviewOpinion"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('production frontend hygiene', () => {
|
||||
it('keeps Starter demonstrations and Mock fixtures outside the production source tree', () => {
|
||||
const output = execFileSync(process.execPath, ['scripts/check-production-hygiene.mjs'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(output).toContain('Production hygiene passed');
|
||||
});
|
||||
|
||||
it('runs the dist scan as part of every release build', () => {
|
||||
const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
|
||||
expect(packageJson.scripts.build).toContain('npm run check:dist');
|
||||
expect(packageJson.scripts['check:dist']).toBe('node scripts/check-production-hygiene.mjs --dist');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const receivableApi = readFileSync(resolve(process.cwd(), 'src/api/receivable.ts'), 'utf8');
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/pages/finance/ReceiptsInvoicesPage.vue'), 'utf8');
|
||||
|
||||
describe('oa-08 returned invoice source correction', () => {
|
||||
it('exposes the source document and version on invoice views', () => {
|
||||
expect(receivableApi).toContain('sourceDocumentId?: string | null');
|
||||
expect(receivableApi).toContain('sourceVersionId?: string | null');
|
||||
});
|
||||
|
||||
it('requires source return points and sends them through the review command', () => {
|
||||
expect(page).toContain('v-model="actionData.returnPoints"');
|
||||
expect(page).toContain(':options="oa08ReturnPointOptions"');
|
||||
expect(page).toContain("returnPoints: actionData.decision === 'RETURN' ? actionData.returnPoints : []");
|
||||
expect(page).toContain('请选择至少一个需要更正的来源字段');
|
||||
});
|
||||
|
||||
it('uses the existing form editor for source correction and hides direct rejection', () => {
|
||||
expect(page).toContain("name: 'FormEditor'");
|
||||
expect(page).toContain("params: { formType: 'OA-08', documentId: sourceDocumentId }");
|
||||
expect(page).toContain('v-if="!sourceReviewInvoice" value="REJECT"');
|
||||
expect(page).toContain("invoiceDetail.sourceDocumentId && invoiceDetail.status === 'RETURNED'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeAuditExport } from '../src/api/audit';
|
||||
import { normalizeReportExport } from '../src/api/reports';
|
||||
|
||||
describe('reports and governance frontend response contracts', () => {
|
||||
it('accepts complete report export confirmations only', () => {
|
||||
expect(
|
||||
normalizeReportExport({
|
||||
exportId: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
reportCode: 'project-ledger',
|
||||
definitionVersion: 'REPORT-DRAFT-V3',
|
||||
filterHash: 'a'.repeat(64),
|
||||
status: 'COMPLETED',
|
||||
estimatedRows: 1,
|
||||
rowCount: 1,
|
||||
sha256: 'b'.repeat(64),
|
||||
fileName: 'report.csv',
|
||||
content: 'header\\nvalue',
|
||||
}),
|
||||
).toMatchObject({ rowCount: 1, fileName: 'report.csv' });
|
||||
});
|
||||
|
||||
it('rejects incomplete report export confirmations before a download can be reported as successful', () => {
|
||||
expect(() => normalizeReportExport({ exportId: 'export' } as any)).toThrow('报表导出响应缺少');
|
||||
});
|
||||
|
||||
it('rejects incomplete audit export responses before a download can be reported as successful', () => {
|
||||
expect(() => normalizeAuditExport({ exportId: 'export', rowCount: 1 } as any)).toThrow('文件摘要');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { hasPermissionAccess } from '../src/store/modules/menu-access';
|
||||
|
||||
describe('system administrator permission semantics', () => {
|
||||
it('treats the isolated system administrator as having every UI permission', () => {
|
||||
expect(hasPermissionAccess('SYSTEM_ADMIN', [], 'project:project:view')).toBe(true);
|
||||
expect(hasPermissionAccess('SYSTEM_ADMIN', [], 'payment:request:approve')).toBe(true);
|
||||
expect(hasPermissionAccess('SYSTEM_ADMIN', [], 'permission:introduced:after-login')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps ordinary roles restricted to their explicit permission list', () => {
|
||||
const permissions = ['project:project:view'];
|
||||
expect(hasPermissionAccess('PROJECT_MANAGER', permissions, 'project:project:view')).toBe(true);
|
||||
expect(hasPermissionAccess('PROJECT_MANAGER', permissions, 'payment:request:approve')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { WorkbenchView } from '../src/api/workbench';
|
||||
import { normalizeWorkbench } from '../src/api/workbench';
|
||||
import { parseWorkbenchTarget } from '../src/pages/workbench/workbench-navigation';
|
||||
|
||||
describe('workbench R3 contracts', () => {
|
||||
it('normalizes every optional workbench collection to an array', () => {
|
||||
expect(
|
||||
normalizeWorkbench({
|
||||
title: '资料管理工作台',
|
||||
archiveItems: null,
|
||||
activities: undefined,
|
||||
} as unknown as Partial<WorkbenchView>),
|
||||
).toMatchObject({
|
||||
title: '资料管理工作台',
|
||||
metrics: [],
|
||||
todos: [],
|
||||
projects: [],
|
||||
issues: [],
|
||||
funds: [],
|
||||
archiveItems: [],
|
||||
activities: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps download drilldowns within the archive file allowlist', () => {
|
||||
expect(parseWorkbenchTarget('/archives/files')).toEqual({ path: '/archives/files' });
|
||||
expect(parseWorkbenchTarget('/archives/files?token=SECRET')).toEqual({
|
||||
path: '/archives/files',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user