diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainRouteAndInteractionContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainRouteAndInteractionContractTest.java new file mode 100644 index 000000000..d057a4f62 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainRouteAndInteractionContractTest.java @@ -0,0 +1,188 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.*; + +import java.io.File; +import java.nio.file.Files; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.Test; + +/** + * 业务内部动态路由契约、前端钻取与菜单完整性回归测试 + */ +public class TrainRouteAndInteractionContractTest +{ + private static final String PROJECT_ROOT = findProjectRoot(); + + private static String findProjectRoot() + { + File cur = new File(".").getAbsoluteFile(); + while (cur != null) + { + if (new File(cur, "build_ui").isDirectory() && new File(cur, "RuoYi-Vue").isDirectory()) + { + return cur.getAbsolutePath(); + } + cur = cur.getParentFile(); + } + return new File(".").getAbsolutePath(); + } + + /** + * 1. 验证管理驾驶舱业务待办提醒的目标路由契约: + * 严禁出现 /train/report, /train/extra, /train/accommodation 等非动态路由硬编码; + * 必须准确对齐 /analysis/attendance, /analysis/report, /equip/accommodation, /equip/extra. + */ + @Test + public void testDashboardReminderRouteTargetContract() throws Exception + { + File dashboardVue = new File(PROJECT_ROOT, "build_ui/src/views/train/analysis/dashboard/index.vue"); + assertTrue("dashboard/index.vue 必须存在", dashboardVue.exists()); + + String content = new String(Files.readAllBytes(dashboardVue.toPath()), "UTF-8"); + + // 强校验:禁止出现旧错误硬编码 + assertFalse("严禁在驾驶舱待办中硬编码 /train/report", content.contains("'/train/report'")); + assertFalse("严禁在驾驶舱待办中硬编码 /train/extra", content.contains("'/train/extra'")); + assertFalse("严禁在驾驶舱待办中硬编码 /train/accommodation", content.contains("'/train/accommodation'")); + + // 强校验:必须对齐真实动态路由 + assertTrue("必须包含考勤分析路由 /analysis/attendance", content.contains("'/analysis/attendance'")); + assertTrue("待报到提醒必须指向真实路由 /analysis/report", content.contains("'/analysis/report'")); + assertTrue("住宿预分配提醒必须指向真实业务路由 /equip/accommodation", content.contains("'/equip/accommodation'")); + assertTrue("门禁授权异常提醒必须指向真实路由 /equip/extra", content.contains("'/equip/extra'")); + + // 验证提醒配置化与角色感知 + assertTrue("必须包含 reminders 响应式配置数组", content.contains("reminders()")); + assertTrue("必须包含 handleReminderClick 权限感知处理器", content.contains("handleReminderClick(")); + assertTrue("必须包含 non-clickable 权限样式支持", content.contains("non-clickable")); + } + + /** + * 2. 验证所有有效业务菜单对应的前端 Vue 组件必须在 build_ui 中物理存在 + */ + @Test + public void testActiveTrainingMenuComponentsExistInBuildUi() + { + String[] coreViews = { + "views/train/analysis/dashboard/index.vue", + "views/train/analysis/attendance/index.vue", + "views/train/portrait/index.vue", + "views/train/report/index.vue", + "views/train/accommodation/index.vue", + "views/train/dormitory/index.vue", + "views/train/extra/index.vue", + "views/train/equipment/index.vue", + "views/train/board/index.vue", + "views/train/site/index.vue", + "views/train/classroom/index.vue" + }; + + File buildUiSrc = new File(PROJECT_ROOT, "build_ui/src"); + for (String viewPath : coreViews) + { + File f = new File(buildUiSrc, viewPath); + assertTrue("核心组件物理文件必须存在: " + viewPath, f.exists()); + } + + // 验证已停用的手动任务组件物理上确实不存在,防止误被作为有效组件解析 + File missingComponent = new File(buildUiSrc, "views/monitor/job/manual_index.vue"); + assertFalse("已停用菜单组件 manual_index.vue 物理上不应存在", missingComponent.exists()); + } + + /** + * 3. 验证 013 迁移脚本的内容契约: + * 包含 2165(教学成果)停用、2059(手动任务)停用、2261/2262/2263(住宿管理)创建与后置断言。 + */ + @Test + public void testMigration013ScriptContract() throws Exception + { + File migration013 = new File(PROJECT_ROOT, "RuoYi-Vue/sql/system/kingbase/013_interaction_route_cleanup.sql"); + assertTrue("013 迁移脚本必须存在", migration013.exists()); + + String sql = new String(Files.readAllBytes(migration013.toPath()), "UTF-8"); + + assertTrue("013 必须显式停用教学成果空父菜单 (2165)", sql.contains("menu_id = 2165")); + assertTrue("013 必须显式停用手动任务无效菜单 (2059)", sql.contains("menu_id = 2059")); + assertTrue("013 必须注册住宿管理菜单 (2261)", sql.contains("2261")); + assertTrue("013 必须注册住宿查询权限 (2262)", sql.contains("2262")); + assertTrue("013 必须注册住宿办理权限 (2263)", sql.contains("2263")); + assertTrue("013 必须包含事务边界 BEGIN 与 COMMIT", sql.contains("BEGIN;") && sql.contains("COMMIT;")); + assertTrue("013 必须包含后置断言块 DO $$", sql.contains("DO $$")); + } + + /** + * 4. 验证整个 build_ui 中不存在非法的 /train/report, /train/extra, /train/accommodation 钻取路由 + */ + @Test + public void testNoInvalidInternalRouteRemainsInBuildUi() throws Exception + { + File buildUiSrc = new File(PROJECT_ROOT, "build_ui/src"); + assertTrue(buildUiSrc.exists()); + + File[] vueFiles = findFilesRecursively(buildUiSrc, ".vue"); + Pattern badRoutePattern = Pattern.compile("['\"]/train/(report|extra)['\"]"); + + for (File f : vueFiles) + { + String content = new String(Files.readAllBytes(f.toPath()), "UTF-8"); + Matcher m = badRoutePattern.matcher(content); + assertFalse("文件 " + f.getName() + " 中不得残留 /train/report 或 /train/extra: ", m.find()); + } + } + + /** + * 5. 验证培训管理员 (train_admin) 角色权限安全防线: + * 严禁被授予任何 remove 破坏性权限(包含课程删除、教师删除、学员删除、住宿删除)。 + */ + @Test + public void testTrainAdminDestructivePermissionSafetyBoundary() throws Exception + { + File migration013 = new File(PROJECT_ROOT, "RuoYi-Vue/sql/system/kingbase/013_interaction_route_cleanup.sql"); + String sql = new String(Files.readAllBytes(migration013.toPath()), "UTF-8"); + + // 将 013 脚本按后置断言 DO $$ 拆分为迁移执行块与安全守护断言块 + String[] parts = sql.split("DO \\$\\$"); + assertTrue("013 必须包含独立的后置断言守护块", parts.length >= 2); + String migrationBody = parts[0]; + String assertionBody = parts[1]; + + // 强校验:在迁移执行块中,绝无任何创建或授权删除权限的语句 + assertFalse("013 迁移块严禁定义住宿删除权限", migrationBody.contains("train:accommodation:remove")); + assertFalse("013 迁移块严禁定义学员删除权限", migrationBody.contains("train:student:remove")); + assertFalse("013 迁移块严禁定义教师删除权限", migrationBody.contains("train:teacher:remove")); + assertFalse("013 迁移块严禁定义课程删除权限", migrationBody.contains("train:course:remove")); + + // 强校验:在安全守护断言块中,必须严格校验培训管理员绝无此类破坏性权限 + assertTrue("后置断言必须核查培训管理员无课程删除权限", assertionBody.contains("train:course:remove")); + assertTrue("后置断言必须核查培训管理员无教师删除权限", assertionBody.contains("train:teacher:remove")); + assertTrue("后置断言必须核查培训管理员无学员删除权限", assertionBody.contains("train:student:remove")); + assertTrue("后置断言必须核查培训管理员无住宿删除权限", assertionBody.contains("train:accommodation:remove")); + assertTrue("后置断言必须抛出安全违背异常", assertionBody.contains("Security violation: train_admin role must not have remove permissions")); + } + + private static File[] findFilesRecursively(File dir, String suffix) + { + java.util.List list = new java.util.ArrayList<>(); + scanDir(dir, suffix, list); + return list.toArray(new File[0]); + } + + private static void scanDir(File dir, String suffix, java.util.List list) + { + File[] files = dir.listFiles(); + if (files == null) return; + for (File f : files) + { + if (f.isDirectory()) + { + scanDir(f, suffix, list); + } + else if (f.getName().endsWith(suffix)) + { + list.add(f); + } + } + } +}