|
|
@@ -0,0 +1,288 @@
|
|
|
+package com.payment.platform.module.system;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.beans.factory.config.BeanDefinition;
|
|
|
+import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
|
|
|
+import org.springframework.core.type.filter.AnnotationTypeFilter;
|
|
|
+import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
+import org.springframework.web.bind.annotation.DeleteMapping;
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
+import org.springframework.web.bind.annotation.PatchMapping;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.PutMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import java.lang.annotation.Annotation;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.TreeMap;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * module/system 下**每一个** HTTP 端点都要挂方法级授权。
|
|
|
+ *
|
|
|
+ * 为什么需要这条钉子:这批端点从 Java 平移起就一个 @PreAuthorize 都没有
|
|
|
+ * (SecurityConfig 只有 .anyRequest().authenticated(),即「登录即可」),
|
|
|
+ * 而 Python 版每条路由都挂 AuthPermission([...])。后果不是抽象的:sys_role /
|
|
|
+ * sys_menu / sys_role_menus 在 TenantInnerInterceptor 的 IGNORE_TABLES 里,
|
|
|
+ * 不加租户谓词 ⇒ 任何已登录账号(含普通租户账号)都能 PATCH
|
|
|
+ * /system/role/permission/setting 改写任意角色的授权。
|
|
|
+ *
|
|
|
+ * 这个测试把「哪个端点该要哪个权限串」整张表钉死。它有三个作用:
|
|
|
+ * 1. 漏挂 —— 表里有、代码里没有 ⇒ 红。
|
|
|
+ * 2. 多挂/改串 —— 串不一致 ⇒ 红(权限串写错是静默的:写对了没人知道,
|
|
|
+ * 写错了所有人被 403 挡在门外,而超管直通 ⇒ 拿超管跑的用例一条都测不出)。
|
|
|
+ * 3. 新增端点 —— 代码里有、表里没有 ⇒ 红。这条最重要:新端点默认是**开放**的,
|
|
|
+ * 必须有人显式决定它要哪个串。
|
|
|
+ *
|
|
|
+ * 豁免不是「忘了」:登录入口、SecurityConfig 白名单、以及用户改自己密码/头像这类
|
|
|
+ * 自助接口不该被权限串挡住。豁免项在表里写成空串,且必须**显式列出** —— 表里
|
|
|
+ * 没有的端点一样会红。
|
|
|
+ */
|
|
|
+class SystemEndpointAuthzTest {
|
|
|
+
|
|
|
+ /** 端点 → 权限串。空串 = 有意豁免(登录 / 白名单 / 自助)。 */
|
|
|
+ private static Map<String, String> expected() {
|
|
|
+ Map<String, String> m = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ // ---- 登录入口:SecurityConfig WHITE_LIST,本就不该要权限串 ----
|
|
|
+ m.put("POST /system/auth/login", "");
|
|
|
+ m.put("POST /system/auth/login/mini", "");
|
|
|
+ m.put("POST /system/auth/login/sms", "");
|
|
|
+ m.put("GET /system/auth/captcha/get", "");
|
|
|
+ m.put("POST /system/auth/logout", "");
|
|
|
+ m.put("POST /system/auth/token/refresh", "");
|
|
|
+ m.put("GET /system/auth/auto-login/users", "");
|
|
|
+ m.put("POST /system/auth/auto-login/token", "");
|
|
|
+ m.put("POST /system/auth/auto-login", "");
|
|
|
+ m.put("POST /system/auth/sms-code", "");
|
|
|
+
|
|
|
+ // ---- 部门 ----
|
|
|
+ m.put("GET /system/dept/tree", "module_system:dept:query");
|
|
|
+ m.put("GET /system/dept/detail/{id}", "module_system:dept:detail");
|
|
|
+ m.put("POST /system/dept/create", "module_system:dept:create");
|
|
|
+ m.put("PUT /system/dept/update/{id}", "module_system:dept:update");
|
|
|
+ m.put("DELETE /system/dept/delete", "module_system:dept:delete");
|
|
|
+ m.put("PATCH /system/dept/available/setting", "module_system:dept:patch");
|
|
|
+
|
|
|
+ // ---- 字典 ----
|
|
|
+ m.put("GET /system/dict/type/detail/{id}", "module_system:dict_type:detail");
|
|
|
+ m.put("GET /system/dict/type/list", "module_system:dict_type:query");
|
|
|
+ m.put("GET /system/dict/type/optionselect", "module_system:dict_type:query");
|
|
|
+ m.put("GET /system/dict/type/page", "module_system:dict_type:query");
|
|
|
+ m.put("POST /system/dict/type/create", "module_system:dict_type:create");
|
|
|
+ m.put("PUT /system/dict/type/update/{id}", "module_system:dict_type:update");
|
|
|
+ m.put("DELETE /system/dict/type/delete", "module_system:dict_type:delete");
|
|
|
+ m.put("PATCH /system/dict/type/available/setting", "module_system:dict_type:patch");
|
|
|
+ m.put("POST /system/dict/type/export", "module_system:dict_type:export");
|
|
|
+ m.put("GET /system/dict/data/info/{dict_type}", "module_system:dict_data:query");
|
|
|
+ m.put("GET /system/dict/data/detail/{id}", "module_system:dict_data:detail");
|
|
|
+ m.put("GET /system/dict/data/list", "module_system:dict_data:query");
|
|
|
+ m.put("POST /system/dict/data/create", "module_system:dict_data:create");
|
|
|
+ m.put("PUT /system/dict/data/update/{id}", "module_system:dict_data:update");
|
|
|
+ m.put("DELETE /system/dict/data/delete", "module_system:dict_data:delete");
|
|
|
+ m.put("PATCH /system/dict/data/available/setting", "module_system:dict_data:patch");
|
|
|
+ m.put("POST /system/dict/data/export", "module_system:dict_data:export");
|
|
|
+
|
|
|
+ // ---- 邀请码 ----
|
|
|
+ m.put("POST /system/invite/generate", "module_system:invitation:create");
|
|
|
+ m.put("GET /system/invite/list", "module_system:invitation:list");
|
|
|
+ m.put("DELETE /system/invite/delete", "module_system:invitation:delete");
|
|
|
+
|
|
|
+ // ---- 操作日志 ----
|
|
|
+ m.put("GET /system/log/list", "module_system:log:query");
|
|
|
+ m.put("GET /system/log/detail/{id}", "module_system:log:detail");
|
|
|
+ m.put("DELETE /system/log/delete", "module_system:log:delete");
|
|
|
+ m.put("POST /system/log/export", "module_system:log:export");
|
|
|
+
|
|
|
+ // ---- 菜单 ----
|
|
|
+ m.put("GET /system/menu/tree", "module_system:menu:query");
|
|
|
+ m.put("GET /system/menu/detail/{id}", "module_system:menu:detail");
|
|
|
+ m.put("POST /system/menu/create", "module_system:menu:create");
|
|
|
+ m.put("PUT /system/menu/update/{id}", "module_system:menu:update");
|
|
|
+ m.put("DELETE /system/menu/delete", "module_system:menu:delete");
|
|
|
+ m.put("PATCH /system/menu/available/setting", "module_system:menu:patch");
|
|
|
+
|
|
|
+ // ---- 公告 ----
|
|
|
+ m.put("GET /system/notice/detail/{id}", "module_system:notice:detail");
|
|
|
+ m.put("GET /system/notice/available", ""); // WHITE_LIST
|
|
|
+ m.put("GET /system/notice/list", "module_system:notice:query");
|
|
|
+ m.put("POST /system/notice/create", "module_system:notice:create");
|
|
|
+ m.put("PUT /system/notice/update/{id}", "module_system:notice:update");
|
|
|
+ m.put("DELETE /system/notice/delete", "module_system:notice:delete");
|
|
|
+ m.put("PATCH /system/notice/available/setting", "module_system:notice:patch");
|
|
|
+ m.put("POST /system/notice/export", "module_system:notice:export");
|
|
|
+
|
|
|
+ // ---- 参数 ----
|
|
|
+ m.put("GET /system/param/detail/{id}", "module_system:param:detail");
|
|
|
+ m.put("GET /system/param/key/{configKey}", "module_system:param:query");
|
|
|
+ m.put("GET /system/param/value/{configKey}", "module_system:param:query");
|
|
|
+ m.put("GET /system/param/list", "module_system:param:query");
|
|
|
+ m.put("GET /system/param/page", "module_system:param:query");
|
|
|
+ m.put("GET /system/param/info", ""); // WHITE_LIST
|
|
|
+ m.put("POST /system/param/create", "module_system:param:create");
|
|
|
+ m.put("PUT /system/param/update/{id}", "module_system:param:update");
|
|
|
+ m.put("DELETE /system/param/delete", "module_system:param:delete");
|
|
|
+ m.put("POST /system/param/upload", "module_system:param:upload");
|
|
|
+ m.put("POST /system/param/export", "module_system:param:export");
|
|
|
+
|
|
|
+ // ---- 岗位 ----
|
|
|
+ m.put("GET /system/position/list", "module_system:position:query");
|
|
|
+ m.put("GET /system/position/detail/{id}", "module_system:position:detail");
|
|
|
+ m.put("POST /system/position/create", "module_system:position:create");
|
|
|
+ m.put("PUT /system/position/update/{id}", "module_system:position:update");
|
|
|
+ m.put("DELETE /system/position/delete", "module_system:position:delete");
|
|
|
+ m.put("PATCH /system/position/available/setting", "module_system:position:patch");
|
|
|
+ m.put("POST /system/position/export", "module_system:position:export");
|
|
|
+
|
|
|
+ // ---- 角色 ----
|
|
|
+ m.put("GET /system/role/list", "module_system:role:query");
|
|
|
+ m.put("GET /system/role/detail/{id}", "module_system:role:detail");
|
|
|
+ m.put("POST /system/role/create", "module_system:role:create");
|
|
|
+ m.put("PUT /system/role/update/{id}", "module_system:role:update");
|
|
|
+ m.put("DELETE /system/role/delete", "module_system:role:delete");
|
|
|
+ m.put("PATCH /system/role/available/setting", "module_system:role:patch");
|
|
|
+ m.put("PATCH /system/role/permission/setting", "module_system:role:permission");
|
|
|
+ m.put("POST /system/role/export", "module_system:role:export");
|
|
|
+
|
|
|
+ // ---- 租户 ----
|
|
|
+ m.put("GET /system/tenant/list", "module_system:tenant:query");
|
|
|
+ m.put("GET /system/tenant/detail/{id}", "module_system:tenant:query");
|
|
|
+ m.put("POST /system/tenant/create", "module_system:tenant:create");
|
|
|
+ m.put("PUT /system/tenant/update/{id}", "module_system:tenant:update");
|
|
|
+ m.put("DELETE /system/tenant/delete", "module_system:tenant:delete");
|
|
|
+ m.put("PATCH /system/tenant/available/setting", "module_system:tenant:patch");
|
|
|
+
|
|
|
+ // ---- 用户 ----
|
|
|
+ m.put("GET /system/user/current/info", ""); // WHITE_LIST
|
|
|
+ m.put("GET /system/user/list", "module_system:user:query");
|
|
|
+ m.put("GET /system/user/detail/{id}", "module_system:user:detail");
|
|
|
+ m.put("POST /system/user/create", "module_system:user:create");
|
|
|
+ m.put("PUT /system/user/update/{id}", "module_system:user:update");
|
|
|
+ m.put("DELETE /system/user/delete", "module_system:user:delete");
|
|
|
+ m.put("PATCH /system/user/available/setting", "module_system:user:patch");
|
|
|
+ m.put("PUT /system/user/current/password/change", ""); // 自助
|
|
|
+ m.put("PUT /system/user/reset/password", "module_system:user:update");
|
|
|
+ m.put("PUT /system/user/current/info/update", ""); // 自助
|
|
|
+ m.put("POST /system/user/current/avatar/upload", ""); // 自助
|
|
|
+ m.put("POST /system/user/register", ""); // WHITE_LIST
|
|
|
+ m.put("POST /system/user/forget/password", ""); // WHITE_LIST
|
|
|
+ m.put("POST /system/user/export", "module_system:user:export");
|
|
|
+ m.put("GET /system/user/import/template", "module_system:user:download");
|
|
|
+ m.put("POST /system/user/import/data", "module_system:user:import");
|
|
|
+
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void everySystemEndpointCarriesTheExpectedGuard() {
|
|
|
+ Map<String, String> expected = expected();
|
|
|
+ Map<String, String> actual = scanEndpoints();
|
|
|
+
|
|
|
+ assertEquals(new TreeMap<>(expected).keySet(), new TreeMap<>(actual).keySet(),
|
|
|
+ "端点集合对不上:代码里有表里没有的(新端点默认开放,必须显式决定它的权限串),"
|
|
|
+ + "或表里有代码里没有的(端点被改名/删掉)。实测=" + new TreeMap<>(actual).keySet());
|
|
|
+
|
|
|
+ StringBuilder bad = new StringBuilder();
|
|
|
+ for (Map.Entry<String, String> e : expected.entrySet()) {
|
|
|
+ String got = actual.get(e.getKey());
|
|
|
+ if (!e.getValue().equals(got)) {
|
|
|
+ bad.append("\n ").append(e.getKey())
|
|
|
+ .append(" 期望=").append(e.getValue().isEmpty() ? "(豁免)" : e.getValue())
|
|
|
+ .append(" 实际=").append(got == null ? "(豁免)" : got);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(bad.length() == 0,
|
|
|
+ "以下端点的权限串与表不一致(注意:串写错是静默的 —— 超管直通,"
|
|
|
+ + "所以用超管跑不出这个缺陷):" + bad);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 端点数必须等于表里的行数,防止扫描漏掉一个控制器却因为「两边都少」而假绿。 */
|
|
|
+ @Test
|
|
|
+ void theTableItselfCoversAllTwelveControllers() {
|
|
|
+ assertEquals(102, expected().size(), "表本身的行数变了,先确认是有意改动");
|
|
|
+
|
|
|
+ Map<String, Long> perController = new TreeMap<>();
|
|
|
+ for (String route : scanEndpoints().keySet()) {
|
|
|
+ String[] parts = route.split(" ")[1].split("/");
|
|
|
+ perController.merge(parts[2], 1L, Long::sum);
|
|
|
+ }
|
|
|
+ assertEquals(12, perController.size(), "扫到的控制器数不是 12:实测=" + perController);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射扫描 ====================
|
|
|
+
|
|
|
+ private Map<String, String> scanEndpoints() {
|
|
|
+ Map<String, String> out = new TreeMap<>();
|
|
|
+ ClassPathScanningCandidateComponentProvider scanner =
|
|
|
+ new ClassPathScanningCandidateComponentProvider(false);
|
|
|
+ scanner.addIncludeFilter(new AnnotationTypeFilter(RestController.class));
|
|
|
+
|
|
|
+ for (BeanDefinition bd : scanner.findCandidateComponents("com.payment.platform.module.system")) {
|
|
|
+ Class<?> clazz;
|
|
|
+ try {
|
|
|
+ clazz = Class.forName(bd.getBeanClassName());
|
|
|
+ } catch (ClassNotFoundException ex) {
|
|
|
+ throw new IllegalStateException("扫到的类加载不了: " + bd.getBeanClassName(), ex);
|
|
|
+ }
|
|
|
+ String base = firstPath(clazz.getAnnotation(RequestMapping.class));
|
|
|
+ for (Method method : clazz.getDeclaredMethods()) {
|
|
|
+ for (Annotation a : method.getAnnotations()) {
|
|
|
+ String verb = verbOf(a);
|
|
|
+ if (verb == null) continue;
|
|
|
+ String path = join(base, firstPath(a));
|
|
|
+ PreAuthorize guard = method.getAnnotation(PreAuthorize.class);
|
|
|
+ out.put(verb + " " + path, guard == null ? "" : permissionOf(guard));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String verbOf(Annotation a) {
|
|
|
+ if (a instanceof GetMapping) return "GET";
|
|
|
+ if (a instanceof PostMapping) return "POST";
|
|
|
+ if (a instanceof PutMapping) return "PUT";
|
|
|
+ if (a instanceof PatchMapping) return "PATCH";
|
|
|
+ if (a instanceof DeleteMapping) return "DELETE";
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @perm.hasAny('<权限串>') —— 整个形式都要对:bean 名写错、hasAll/hasAny 写错都该红。 */
|
|
|
+ private static final java.util.regex.Pattern GUARD =
|
|
|
+ java.util.regex.Pattern.compile("^@perm\\.hasAny\\('([^']+)'\\)$");
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从 @PreAuthorize 的 SpEL 里取出权限串。
|
|
|
+ * 形式不合时**原样返回整条 SpEL**(不去抛异常):这样它会落进上面那张差异表里,
|
|
|
+ * 与期望串并排显示,且一条写坏不会遮住另外 101 个端点的读数。
|
|
|
+ * 原样串以 '@' 开头,不可能等于期望串,所以不会假绿。
|
|
|
+ */
|
|
|
+ private static String permissionOf(PreAuthorize guard) {
|
|
|
+ java.util.regex.Matcher m = GUARD.matcher(guard.value().trim());
|
|
|
+ return m.matches() ? m.group(1) : guard.value().trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 取出映射注解的路径:value() 与 path() 都算(@PostMapping(value = "/login") 用的是 value)。 */
|
|
|
+ private static String firstPath(Annotation a) {
|
|
|
+ if (a == null) return "";
|
|
|
+ String[] candidates;
|
|
|
+ if (a instanceof RequestMapping rm) candidates = rm.value().length > 0 ? rm.value() : rm.path();
|
|
|
+ else if (a instanceof GetMapping gm) candidates = gm.value().length > 0 ? gm.value() : gm.path();
|
|
|
+ else if (a instanceof PostMapping pm) candidates = pm.value().length > 0 ? pm.value() : pm.path();
|
|
|
+ else if (a instanceof PutMapping pm) candidates = pm.value().length > 0 ? pm.value() : pm.path();
|
|
|
+ else if (a instanceof PatchMapping pm) candidates = pm.value().length > 0 ? pm.value() : pm.path();
|
|
|
+ else if (a instanceof DeleteMapping dm) candidates = dm.value().length > 0 ? dm.value() : dm.path();
|
|
|
+ else return "";
|
|
|
+ return candidates.length == 0 ? "" : candidates[0];
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String join(String base, String path) {
|
|
|
+ if (path.isEmpty()) return base;
|
|
|
+ String b = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
|
|
|
+ return path.startsWith("/") ? b + path : b + "/" + path;
|
|
|
+ }
|
|
|
+}
|