测试路由:增加动态菜单与前端钻取契约检查

This commit is contained in:
wangzhibiao committed 2026-09-21 00:14:50 +08:00
1 parent ed08e1deb3
commit ec3a7afe46
1 file changed
+188
@@ -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<File> 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<File> 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);
}
}
}
}