✅ 权限表达式抽为共享常量并补可求值性测试
@PreAuth 里的 SpEL 字符串由框架在**请求时**求值,root object 是 AuthFun。 函数名写错既不过编译期、也不在启动期暴露,只会在真实调用时抛异常 (表现为接口 500)——正好是最难在开发阶段发现的一类错。 故把两个表达式抽到 CertAuditPermissions,并在测试中用与框架相同的 SpelExpressionParser + AuthFun 作为 root object 实际解析求值,覆盖: - 只授司机菜单 / 只授车辆菜单 / 两个都没有; - 只有查看权限、没有审核按钮权限时必须拒绝(能看≠能审); - 表达式必须引用票 01 建的真实菜单码,且不得再出现角色名判断。 顺带消除控制器里与共享常量重复的两份字符串。 6 个新用例;全模块 79 个测试通过。
This commit is contained in:
+4
-22
@@ -37,6 +37,7 @@ import org.springblade.core.tool.api.R;
|
|||||||
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
|
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
|
||||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||||
import org.springblade.transport.service.ICertAuditService;
|
import org.springblade.transport.service.ICertAuditService;
|
||||||
|
import org.springblade.transport.support.CertAuditPermissions;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -56,31 +57,12 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@Tag(name = "认证审核", description = "管理端司机认证与车辆认证审核")
|
@Tag(name = "认证审核", description = "管理端司机认证与车辆认证审核")
|
||||||
public class CertificationAuditController extends BladeController {
|
public class CertificationAuditController extends BladeController {
|
||||||
|
|
||||||
/**
|
|
||||||
* 待审列表与详情的权限:司机或车辆任一审核菜单即可。
|
|
||||||
* <p>
|
|
||||||
* 用菜单码而不是角色名——本项目的权限走「角色-菜单」授权,
|
|
||||||
* 写死角色名会漏掉实际承担审核的岗位(例如「运管」),
|
|
||||||
* 也会在角色改名后静默失效。
|
|
||||||
* <p>
|
|
||||||
* 用 SpEL 而非 {@code @PreAuth(menu = "...")},是因为同一组接口同时服务
|
|
||||||
* 司机与车辆两侧(由 {@code bizType} 区分),单值 menu 表达不了「二者之一」。
|
|
||||||
*/
|
|
||||||
private static final String AUDIT_MENU_EXPRESSION =
|
|
||||||
"hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 审核动作的权限:司机或车辆任一「审核」按钮权限即可
|
|
||||||
*/
|
|
||||||
private static final String AUDIT_ACTION_MENU_EXPRESSION =
|
|
||||||
"hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')";
|
|
||||||
|
|
||||||
private final ICertAuditService certAuditService;
|
private final ICertAuditService certAuditService;
|
||||||
|
|
||||||
@GetMapping("/pending-page")
|
@GetMapping("/pending-page")
|
||||||
@ApiOperationSupport(order = 1)
|
@ApiOperationSupport(order = 1)
|
||||||
@Operation(summary = "待审列表", description = "按认证对象类型分页查询审核中的记录,按提交时间倒序")
|
@Operation(summary = "待审列表", description = "按认证对象类型分页查询审核中的记录,按提交时间倒序")
|
||||||
@PreAuth(AUDIT_MENU_EXPRESSION)
|
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
|
||||||
public R<IPage<TransportCertRecordVO>> pendingPage(
|
public R<IPage<TransportCertRecordVO>> pendingPage(
|
||||||
@Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true)
|
@Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true)
|
||||||
@RequestParam String bizType,
|
@RequestParam String bizType,
|
||||||
@@ -94,7 +76,7 @@ public class CertificationAuditController extends BladeController {
|
|||||||
@GetMapping("/detail")
|
@GetMapping("/detail")
|
||||||
@ApiOperationSupport(order = 2)
|
@ApiOperationSupport(order = 2)
|
||||||
@Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比")
|
@Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比")
|
||||||
@PreAuth(AUDIT_MENU_EXPRESSION)
|
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
|
||||||
public R<CertAuditDetailVO> detail(
|
public R<CertAuditDetailVO> detail(
|
||||||
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id) {
|
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id) {
|
||||||
return R.data(certAuditService.detail(id));
|
return R.data(certAuditService.detail(id));
|
||||||
@@ -103,7 +85,7 @@ public class CertificationAuditController extends BladeController {
|
|||||||
@PostMapping("/audit")
|
@PostMapping("/audit")
|
||||||
@ApiOperationSupport(order = 3)
|
@ApiOperationSupport(order = 3)
|
||||||
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变")
|
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变")
|
||||||
@PreAuth(AUDIT_ACTION_MENU_EXPRESSION)
|
@PreAuth(CertAuditPermissions.AUDIT_ACTION)
|
||||||
public R<Boolean> audit(
|
public R<Boolean> audit(
|
||||||
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id,
|
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id,
|
||||||
@Parameter(description = "是否通过", required = true) @RequestParam Boolean pass,
|
@Parameter(description = "是否通过", required = true) @RequestParam Boolean pass,
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* BladeX Commercial License Agreement
|
||||||
|
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||||
|
* <p>
|
||||||
|
* Use of this software is governed by the Commercial License Agreement
|
||||||
|
* obtained after purchasing a license from BladeX.
|
||||||
|
* <p>
|
||||||
|
* 1. This software is for development use only under a valid license
|
||||||
|
* from BladeX.
|
||||||
|
* <p>
|
||||||
|
* 2. Redistribution of this software's source code to any third party
|
||||||
|
* without a commercial license is strictly prohibited.
|
||||||
|
* <p>
|
||||||
|
* 3. Licensees may copyright their own code but cannot use segments
|
||||||
|
* from this software for such purposes. Copyright of this software
|
||||||
|
* remains with BladeX.
|
||||||
|
* <p>
|
||||||
|
* Using this software signifies agreement to this License, and the software
|
||||||
|
* must not be used for illegal purposes.
|
||||||
|
* <p>
|
||||||
|
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||||
|
* not liable for any claims arising from secondary or illegal development.
|
||||||
|
* <p>
|
||||||
|
* Author: Chill Zhuang (bladejava@qq.com)
|
||||||
|
*/
|
||||||
|
package org.springblade.transport.support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证审核的权限表达式
|
||||||
|
* <p>
|
||||||
|
* 用菜单码而不是角色名。本项目的权限走「角色-菜单」授权,写死角色名有两个坑:
|
||||||
|
* 角色别名可能对不上(曾写成 {@code admin},而库里只有 {@code administrator}),
|
||||||
|
* 以及会漏掉实际承担审核的岗位(例如「运管」)。
|
||||||
|
* <p>
|
||||||
|
* 用表达式而不是 {@code @PreAuth(menu = "...")} 的单值形式,是因为同一组接口
|
||||||
|
* 由 {@code bizType} 参数区分、同时服务司机与车辆两侧,单值 menu 表达不了「二者之一」。
|
||||||
|
* <p>
|
||||||
|
* 表达式最终由框架交给 SpEL 求值,root object 是 {@code AuthFun},
|
||||||
|
* 故这里用的函数名必须与它的公开方法一致({@code hasMenu})。
|
||||||
|
* {@code CertAuditPermissionsTest} 会实际解析并求值这两个表达式,
|
||||||
|
* 一旦写错函数名或括号,测试即失败——而不是等到线上请求时抛 500。
|
||||||
|
*
|
||||||
|
* @author Chill
|
||||||
|
*/
|
||||||
|
public final class CertAuditPermissions {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待审列表与审核详情:司机或车辆任一审核菜单即可
|
||||||
|
*/
|
||||||
|
public static final String AUDIT_VIEW =
|
||||||
|
"hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核动作:司机或车辆任一「审核」按钮权限即可
|
||||||
|
*/
|
||||||
|
public static final String AUDIT_ACTION =
|
||||||
|
"hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')";
|
||||||
|
|
||||||
|
private CertAuditPermissions() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package org.springblade.transport.support;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springblade.core.secure.auth.AuthFun;
|
||||||
|
import org.springframework.expression.Expression;
|
||||||
|
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||||
|
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证审核权限表达式的可求值性测试。
|
||||||
|
* <p>
|
||||||
|
* 这些字符串写在 {@code @PreAuth} 注解里,由框架在**请求时**交给 SpEL 求值,
|
||||||
|
* root object 是 {@code AuthFun}。名字写错不会在编译期或启动期暴露,
|
||||||
|
* 只会在真实调用时抛异常(表现为接口 500)——用测试把它提前到构建期。
|
||||||
|
* <p>
|
||||||
|
* 这里用同一个 {@link SpelExpressionParser} 与同一套 root object 语义求值,
|
||||||
|
* 与框架 {@code AuthAspect} 的做法一致。
|
||||||
|
*/
|
||||||
|
class CertAuditPermissionsTest {
|
||||||
|
|
||||||
|
private final SpelExpressionParser parser = new SpelExpressionParser();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("查看权限:拥有司机审核菜单即可通过")
|
||||||
|
void auditViewWithDriverMenu() {
|
||||||
|
AuthFun authFun = mock(AuthFun.class);
|
||||||
|
when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true);
|
||||||
|
|
||||||
|
assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun),
|
||||||
|
"只授予司机审核菜单的用户也应能查看待审列表");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("查看权限:拥有车辆审核菜单即可通过")
|
||||||
|
void auditViewWithVehicleMenu() {
|
||||||
|
AuthFun authFun = mock(AuthFun.class);
|
||||||
|
when(authFun.hasMenu("cert_certification_audit_vehicle")).thenReturn(true);
|
||||||
|
|
||||||
|
assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("查看权限:两个菜单都没有则拒绝")
|
||||||
|
void auditViewWithoutAnyMenu() {
|
||||||
|
assertFalse(evaluate(CertAuditPermissions.AUDIT_VIEW, mock(AuthFun.class)),
|
||||||
|
"无任何审核菜单的用户不得查看待审列表");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("审核动作:只有查看权限、没有审核按钮权限时必须拒绝")
|
||||||
|
void auditActionRequiresButtonPermission() {
|
||||||
|
AuthFun authFun = mock(AuthFun.class);
|
||||||
|
// 只给了列表菜单,没给「审核」按钮
|
||||||
|
when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true);
|
||||||
|
|
||||||
|
assertFalse(evaluate(CertAuditPermissions.AUDIT_ACTION, authFun),
|
||||||
|
"能看待审列表不等于能执行审核");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("审核动作:任一审核按钮权限即可通过")
|
||||||
|
void auditActionWithEitherButton() {
|
||||||
|
AuthFun driverAuditor = mock(AuthFun.class);
|
||||||
|
when(driverAuditor.hasMenu("cert_certification_audit_driver_audit")).thenReturn(true);
|
||||||
|
assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, driverAuditor));
|
||||||
|
|
||||||
|
AuthFun vehicleAuditor = mock(AuthFun.class);
|
||||||
|
when(vehicleAuditor.hasMenu("cert_certification_audit_vehicle_audit")).thenReturn(true);
|
||||||
|
assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, vehicleAuditor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("表达式必须引用真实存在的菜单码(与票 01 建的一致)")
|
||||||
|
void expressionsReferenceRealMenuCodes() {
|
||||||
|
assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_driver"));
|
||||||
|
assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_vehicle"));
|
||||||
|
assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_driver_audit"));
|
||||||
|
assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_vehicle_audit"));
|
||||||
|
// 不应再出现角色名判断:库里不存在 admin 角色,且会漏掉「运管」岗位
|
||||||
|
assertFalse(CertAuditPermissions.AUDIT_VIEW.contains("hasAnyRole"));
|
||||||
|
assertFalse(CertAuditPermissions.AUDIT_ACTION.contains("hasAnyRole"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用与框架相同的方式求值:AuthFun 作为 root object
|
||||||
|
*/
|
||||||
|
private boolean evaluate(String expression, AuthFun authFun) {
|
||||||
|
Expression parsed = parser.parseExpression(expression);
|
||||||
|
StandardEvaluationContext context = new StandardEvaluationContext(authFun);
|
||||||
|
return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user