Commit 2f655a816beba1d5dd02d6c49e992f667b0392ad
1 parent
19829c6c
fix: 修复钱包充值退款权限与 IoT 固件上传校验
- 钱包充值退款复用用户余额修改权限,阻止无权限用户发起退款(#1328) - 允许 .bin 固件上传、预签名及直传登记,保留未知后缀和内容类型校验 - 补充方法权限拦截与固件上传回归用例 星球反馈:https://t.zsxq.com/bAppp
Showing
4 changed files
with
309 additions
and
5 deletions
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
| @@ -22,9 +22,11 @@ import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; | @@ -22,9 +22,11 @@ import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; | ||
| 22 | import com.google.common.annotations.VisibleForTesting; | 22 | import com.google.common.annotations.VisibleForTesting; |
| 23 | import jakarta.annotation.Resource; | 23 | import jakarta.annotation.Resource; |
| 24 | import lombok.SneakyThrows; | 24 | import lombok.SneakyThrows; |
| 25 | +import org.apache.tika.mime.MimeTypes; | ||
| 25 | import org.springframework.stereotype.Service; | 26 | import org.springframework.stereotype.Service; |
| 26 | 27 | ||
| 27 | import java.util.List; | 28 | import java.util.List; |
| 29 | +import java.util.Locale; | ||
| 28 | import java.util.Set; | 30 | import java.util.Set; |
| 29 | 31 | ||
| 30 | import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; | 32 | import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; |
| @@ -63,6 +65,14 @@ public class FileServiceImpl implements FileService { | @@ -63,6 +65,14 @@ public class FileServiceImpl implements FileService { | ||
| 63 | ); | 65 | ); |
| 64 | 66 | ||
| 65 | /** | 67 | /** |
| 68 | + * 允许上传的二进制文件后缀,例如说 IoT OTA 固件的 .bin | ||
| 69 | + * | ||
| 70 | + * 为什么不直接放行 application/octet-stream?它也是所有未知后缀、未知内容的识别结果,放行后等于不限制类型。 | ||
| 71 | + * 所以只放行这里的后缀,并且上传内容也必须识别为 application/octet-stream(不能是可执行文件、脚本、HTML 等) | ||
| 72 | + */ | ||
| 73 | + private static final Set<String> ALLOWED_BINARY_EXTENSIONS = Set.of("bin"); | ||
| 74 | + | ||
| 75 | + /** | ||
| 66 | * 上传文件的前缀,是否包含日期(yyyyMMdd) | 76 | * 上传文件的前缀,是否包含日期(yyyyMMdd) |
| 67 | * | 77 | * |
| 68 | * 目的:按照日期,进行分目录 | 78 | * 目的:按照日期,进行分目录 |
| @@ -106,8 +116,10 @@ public class FileServiceImpl implements FileService { | @@ -106,8 +116,10 @@ public class FileServiceImpl implements FileService { | ||
| 106 | } | 116 | } |
| 107 | type = FileTypeUtils.getMineType(content, name); | 117 | type = FileTypeUtils.getMineType(content, name); |
| 108 | String nameType = StrUtil.isNotEmpty(FileUtil.extName(name)) ? validateFileType(name) : null; | 118 | String nameType = StrUtil.isNotEmpty(FileUtil.extName(name)) ? validateFileType(name) : null; |
| 109 | - if (!ALLOWED_FILE_TYPES.contains(type) | ||
| 110 | - || (FileTypeUtils.isImage(nameType) && ObjectUtil.notEqual(type, nameType))) { | 119 | + if (!isAllowedFileType(type, name) |
| 120 | + // 图片、二进制文件(例如说 .bin 固件):内容识别的类型,必须与后缀一致 | ||
| 121 | + || ((FileTypeUtils.isImage(nameType) || isAllowedBinaryFile(nameType, name)) | ||
| 122 | + && ObjectUtil.notEqual(type, nameType))) { | ||
| 111 | throw exception(FILE_TYPE_NOT_ALLOWED); | 123 | throw exception(FILE_TYPE_NOT_ALLOWED); |
| 112 | } | 124 | } |
| 113 | // 1.2.2 处理 name 为空的情况 | 125 | // 1.2.2 处理 name 为空的情况 |
| @@ -221,12 +233,24 @@ public class FileServiceImpl implements FileService { | @@ -221,12 +233,24 @@ public class FileServiceImpl implements FileService { | ||
| 221 | 233 | ||
| 222 | private String validateFileType(String name) { | 234 | private String validateFileType(String name) { |
| 223 | String type = FileTypeUtils.getMineType(name); | 235 | String type = FileTypeUtils.getMineType(name); |
| 224 | - if (!ALLOWED_FILE_TYPES.contains(type)) { | 236 | + if (!isAllowedFileType(type, name)) { |
| 225 | throw exception(FILE_TYPE_NOT_ALLOWED); | 237 | throw exception(FILE_TYPE_NOT_ALLOWED); |
| 226 | } | 238 | } |
| 227 | return type; | 239 | return type; |
| 228 | } | 240 | } |
| 229 | 241 | ||
| 242 | + private static boolean isAllowedFileType(String type, String name) { | ||
| 243 | + return ALLOWED_FILE_TYPES.contains(type) || isAllowedBinaryFile(type, name); | ||
| 244 | + } | ||
| 245 | + | ||
| 246 | + private static boolean isAllowedBinaryFile(String type, String name) { | ||
| 247 | + if (ObjectUtil.notEqual(type, MimeTypes.OCTET_STREAM)) { | ||
| 248 | + return false; | ||
| 249 | + } | ||
| 250 | + String extension = FileUtil.extName(name); | ||
| 251 | + return StrUtil.isNotEmpty(extension) && ALLOWED_BINARY_EXTENSIONS.contains(extension.toLowerCase(Locale.ROOT)); | ||
| 252 | + } | ||
| 253 | + | ||
| 230 | @Override | 254 | @Override |
| 231 | public FileDO getFile(Long id) { | 255 | public FileDO getFile(Long id) { |
| 232 | return validateFileExists(id); | 256 | return validateFileExists(id); |
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java
| @@ -322,6 +322,79 @@ public class FileServiceImplTest extends BaseDbUnitTest { | @@ -322,6 +322,79 @@ public class FileServiceImplTest extends BaseDbUnitTest { | ||
| 322 | } | 322 | } |
| 323 | 323 | ||
| 324 | @Test | 324 | @Test |
| 325 | + public void testCreateFile_success_bin() throws Exception { | ||
| 326 | + for (String name : new String[]{"固件.bin", "FIRMWARE.BIN", "firmware.Bin"}) { | ||
| 327 | + // 准备参数:IoT OTA 固件,Tika 无法识别的二进制内容 | ||
| 328 | + byte[] content = buildFirmwareContent(); | ||
| 329 | + // mock 方法 | ||
| 330 | + FileClient client = mock(FileClient.class); | ||
| 331 | + when(fileConfigService.getMasterFileClient()).thenReturn(client); | ||
| 332 | + String url = randomString(); | ||
| 333 | + when(client.upload(same(content), anyString(), eq("application/octet-stream"))).thenReturn(url); | ||
| 334 | + | ||
| 335 | + // 调用:不信任客户端传入的 type | ||
| 336 | + String result = fileService.createFile(content, name, "iot/ota", "image/png"); | ||
| 337 | + // 断言 | ||
| 338 | + assertEquals(url, result); | ||
| 339 | + FileDO file = fileMapper.selectOne(FileDO::getUrl, url); | ||
| 340 | + assertEquals(name, file.getName()); | ||
| 341 | + assertEquals("application/octet-stream", file.getType()); | ||
| 342 | + assertEquals((long) content.length, file.getSize()); | ||
| 343 | + } | ||
| 344 | + } | ||
| 345 | + | ||
| 346 | + @Test | ||
| 347 | + public void testCreateFile_typeNotAllowed_binContent() { | ||
| 348 | + byte[] elf = buildFirmwareContent(); | ||
| 349 | + System.arraycopy(new byte[]{0x7F, 'E', 'L', 'F', 2, 1, 1}, 0, elf, 0, 7); | ||
| 350 | + elf[16] = 2; // e_type = ET_EXEC | ||
| 351 | + byte[] exe = buildFirmwareContent(); | ||
| 352 | + exe[0] = 'M'; | ||
| 353 | + exe[1] = 'Z'; | ||
| 354 | + for (byte[] content : new byte[][]{elf, exe, | ||
| 355 | + "#!/bin/sh\nrm -rf /tmp/test\n".getBytes(StandardCharsets.UTF_8), | ||
| 356 | + "<html><script>alert(1)</script></html>".getBytes(StandardCharsets.UTF_8), | ||
| 357 | + "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>".getBytes(StandardCharsets.UTF_8), | ||
| 358 | + ResourceUtil.readBytes("file/erweima.jpg")}) { | ||
| 359 | + // 调用,并断言异常:.bin 的内容,必须是无法识别为其他类型的二进制 | ||
| 360 | + assertServiceException(() -> fileService.createFile(content, "固件.bin", null, "application/octet-stream"), | ||
| 361 | + FILE_TYPE_NOT_ALLOWED); | ||
| 362 | + // 断言未上传文件、未保存记录 | ||
| 363 | + verifyNoInteractions(fileConfigService); | ||
| 364 | + assertEquals(0L, fileMapper.selectCount()); | ||
| 365 | + } | ||
| 366 | + } | ||
| 367 | + | ||
| 368 | + @Test | ||
| 369 | + public void testCreateFile_typeNotAllowed_binaryOtherExtension() { | ||
| 370 | + for (String name : new String[]{"固件.so", "固件.dump", "固件.unknown", "固件"}) { | ||
| 371 | + // 准备参数:同样识别为 application/octet-stream 的其它后缀,不放行 | ||
| 372 | + byte[] content = buildFirmwareContent(); | ||
| 373 | + | ||
| 374 | + // 调用,并断言异常 | ||
| 375 | + assertServiceException(() -> fileService.createFile(content, name, null, "application/octet-stream"), | ||
| 376 | + FILE_TYPE_NOT_ALLOWED); | ||
| 377 | + // 断言未上传文件、未保存记录 | ||
| 378 | + verifyNoInteractions(fileConfigService); | ||
| 379 | + assertEquals(0L, fileMapper.selectCount()); | ||
| 380 | + } | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + /** | ||
| 384 | + * 构造固件内容:类似 ARM Cortex-M 的向量表开头,包含 0x00 等不可打印字节,Tika 无法识别 | ||
| 385 | + */ | ||
| 386 | + private static byte[] buildFirmwareContent() { | ||
| 387 | + byte[] content = new byte[256]; | ||
| 388 | + byte[] header = new byte[]{0x00, 0x50, 0x00, 0x20, (byte) 0xC1, 0x01, 0x00, 0x08, | ||
| 389 | + (byte) 0xC9, 0x01, 0x00, 0x08, (byte) 0xCB, 0x01, 0x00, 0x08}; | ||
| 390 | + System.arraycopy(header, 0, content, 0, header.length); | ||
| 391 | + for (int i = header.length; i < content.length; i++) { | ||
| 392 | + content[i] = (byte) (i * 7); | ||
| 393 | + } | ||
| 394 | + return content; | ||
| 395 | + } | ||
| 396 | + | ||
| 397 | + @Test | ||
| 325 | public void testCreateFile_fileIsEmpty() { | 398 | public void testCreateFile_fileIsEmpty() { |
| 326 | // 准备参数 | 399 | // 准备参数 |
| 327 | byte[] content = new byte[0]; | 400 | byte[] content = new byte[0]; |
| @@ -337,7 +410,7 @@ public class FileServiceImplTest extends BaseDbUnitTest { | @@ -337,7 +410,7 @@ public class FileServiceImplTest extends BaseDbUnitTest { | ||
| 337 | for (String extension : new String[]{"jpg", "JPEG", "jfif", "png", "gif", "webp", "bmp", "ico", "tiff", | 410 | for (String extension : new String[]{"jpg", "JPEG", "jfif", "png", "gif", "webp", "bmp", "ico", "tiff", |
| 338 | "avif", "heic", "heif", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "md", | 411 | "avif", "heic", "heif", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "md", |
| 339 | "zip", "rar", "mp3", "mp4", "svg", "html", "htm", "xml", "markdown", "eml", "msg", "epub", | 412 | "zip", "rar", "mp3", "mp4", "svg", "html", "htm", "xml", "markdown", "eml", "msg", "epub", |
| 340 | - "json", "opus", "ogg", "m4a", "webm", "amr", "wma", "wmv", "flv", "mkv"}) { | 413 | + "json", "opus", "ogg", "m4a", "webm", "amr", "wma", "wmv", "flv", "mkv", "bin", "BIN"}) { |
| 341 | // 准备参数 | 414 | // 准备参数 |
| 342 | String name = "测试文件." + extension; | 415 | String name = "测试文件." + extension; |
| 343 | // mock 方法 | 416 | // mock 方法 |
| @@ -360,7 +433,8 @@ public class FileServiceImplTest extends BaseDbUnitTest { | @@ -360,7 +433,8 @@ public class FileServiceImplTest extends BaseDbUnitTest { | ||
| 360 | @Test | 433 | @Test |
| 361 | public void testPresignPutUrl_typeNotAllowed() { | 434 | public void testPresignPutUrl_typeNotAllowed() { |
| 362 | // 准备参数 | 435 | // 准备参数 |
| 363 | - for (String name : new String[]{"test.php", "test.jsp", "test.js", "test.exe", "test.unknown", "test"}) { | 436 | + for (String name : new String[]{"test.php", "test.jsp", "test.js", "test.exe", "test.unknown", "test", |
| 437 | + "test.so", "test.dump", "test.sh", "test.bin.exe"}) { | ||
| 364 | // 调用,并断言异常 | 438 | // 调用,并断言异常 |
| 365 | assertServiceException(() -> fileService.presignPutUrl(name, null), FILE_TYPE_NOT_ALLOWED); | 439 | assertServiceException(() -> fileService.presignPutUrl(name, null), FILE_TYPE_NOT_ALLOWED); |
| 366 | // 校验调用 | 440 | // 校验调用 |
| @@ -491,6 +565,47 @@ public class FileServiceImplTest extends BaseDbUnitTest { | @@ -491,6 +565,47 @@ public class FileServiceImplTest extends BaseDbUnitTest { | ||
| 491 | } | 565 | } |
| 492 | 566 | ||
| 493 | @Test | 567 | @Test |
| 568 | + public void testCreateFileByPresignedPath_success_bin() { | ||
| 569 | + // 准备参数:IoT OTA 固件,后缀大小写不敏感 | ||
| 570 | + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { | ||
| 571 | + o.setPath("iot/ota/firmware.bin"); | ||
| 572 | + o.setName("FIRMWARE.BIN"); | ||
| 573 | + o.setUrl("https://www.iocoder.cn/iot/ota/firmware.bin"); | ||
| 574 | + o.setType("text/html"); // 不信任客户端传入的 type | ||
| 575 | + }); | ||
| 576 | + | ||
| 577 | + // 调用 | ||
| 578 | + Long fileId = fileService.createFile(reqVO); | ||
| 579 | + | ||
| 580 | + // 断言 | ||
| 581 | + FileDO file = fileMapper.selectById(fileId); | ||
| 582 | + assertEquals("iot/ota/firmware.bin", file.getPath()); | ||
| 583 | + assertEquals("FIRMWARE.BIN", file.getName()); | ||
| 584 | + assertEquals("application/octet-stream", file.getType()); | ||
| 585 | + } | ||
| 586 | + | ||
| 587 | + @Test | ||
| 588 | + public void testCreateFileByPresignedPath_typeNotAllowed_bin() { | ||
| 589 | + for (String[] file : new String[][]{{"firmware.bin", "iot/firmware.exe"}, {"firmware.bin", "iot/firmware.so"}, | ||
| 590 | + {"firmware.so", "iot/firmware.so"}, {"firmware.exe", "iot/firmware.bin"}, | ||
| 591 | + {"firmware.bin", "iot/firmware.html"}, {"firmware", "iot/firmware.bin"}}) { | ||
| 592 | + // 准备参数 | ||
| 593 | + String name = file[0]; | ||
| 594 | + String path = file[1]; | ||
| 595 | + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { | ||
| 596 | + o.setName(name); | ||
| 597 | + o.setPath(path); | ||
| 598 | + o.setType("application/octet-stream"); | ||
| 599 | + }); | ||
| 600 | + | ||
| 601 | + // 调用,并断言异常 | ||
| 602 | + assertServiceException(() -> fileService.createFile(reqVO), FILE_TYPE_NOT_ALLOWED); | ||
| 603 | + // 断言未保存记录 | ||
| 604 | + assertEquals(0L, fileMapper.selectCount()); | ||
| 605 | + } | ||
| 606 | + } | ||
| 607 | + | ||
| 608 | + @Test | ||
| 494 | public void testCreateFileByPresignedPath_nameInvalid() { | 609 | public void testCreateFileByPresignedPath_nameInvalid() { |
| 495 | // 准备参数 | 610 | // 准备参数 |
| 496 | FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { | 611 | FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeController.java
| @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; | @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; | ||
| 8 | import io.swagger.v3.oas.annotations.Parameter; | 8 | import io.swagger.v3.oas.annotations.Parameter; |
| 9 | import io.swagger.v3.oas.annotations.tags.Tag; | 9 | import io.swagger.v3.oas.annotations.tags.Tag; |
| 10 | import lombok.extern.slf4j.Slf4j; | 10 | import lombok.extern.slf4j.Slf4j; |
| 11 | +import org.springframework.security.access.prepost.PreAuthorize; | ||
| 11 | import org.springframework.validation.annotation.Validated; | 12 | import org.springframework.validation.annotation.Validated; |
| 12 | import org.springframework.web.bind.annotation.*; | 13 | import org.springframework.web.bind.annotation.*; |
| 13 | 14 | ||
| @@ -40,6 +41,7 @@ public class PayWalletRechargeController { | @@ -40,6 +41,7 @@ public class PayWalletRechargeController { | ||
| 40 | @PostMapping("/refund") | 41 | @PostMapping("/refund") |
| 41 | @Operation(summary = "发起钱包充值退款") | 42 | @Operation(summary = "发起钱包充值退款") |
| 42 | @Parameter(name = "id", description = "编号", required = true, example = "1024") | 43 | @Parameter(name = "id", description = "编号", required = true, example = "1024") |
| 44 | + @PreAuthorize("@ss.hasPermission('pay:wallet:update-balance')") // 退款会扣减用户钱包余额,复用「用户余额修改」权限 | ||
| 43 | public CommonResult<Boolean> refundWalletRecharge(@RequestParam("id") Long id) { | 45 | public CommonResult<Boolean> refundWalletRecharge(@RequestParam("id") Long id) { |
| 44 | walletRechargeService.refundWalletRecharge(id, getClientIP()); | 46 | walletRechargeService.refundWalletRecharge(id, getClientIP()); |
| 45 | return success(true); | 47 | return success(true); |
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletRechargeControllerSecurityTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.pay.controller.admin.wallet; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi; | ||
| 4 | +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; | ||
| 5 | +import cn.iocoder.yudao.framework.security.core.LoginUser; | ||
| 6 | +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; | ||
| 7 | +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkServiceImpl; | ||
| 8 | +import cn.iocoder.yudao.module.pay.api.notify.dto.PayOrderNotifyReqDTO; | ||
| 9 | +import cn.iocoder.yudao.module.pay.service.wallet.PayWalletRechargeService; | ||
| 10 | +import jakarta.annotation.Resource; | ||
| 11 | +import org.junit.jupiter.api.AfterEach; | ||
| 12 | +import org.junit.jupiter.api.BeforeEach; | ||
| 13 | +import org.junit.jupiter.api.Test; | ||
| 14 | +import org.springframework.aop.support.AopUtils; | ||
| 15 | +import org.springframework.context.annotation.Bean; | ||
| 16 | +import org.springframework.context.annotation.Configuration; | ||
| 17 | +import org.springframework.security.access.AccessDeniedException; | ||
| 18 | +import org.springframework.security.authentication.AnonymousAuthenticationToken; | ||
| 19 | +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; | ||
| 20 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| 21 | +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; | ||
| 22 | +import org.springframework.security.core.authority.AuthorityUtils; | ||
| 23 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 24 | +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; | ||
| 25 | + | ||
| 26 | +import java.util.Collections; | ||
| 27 | + | ||
| 28 | +import static org.junit.jupiter.api.Assertions.*; | ||
| 29 | +import static org.mockito.ArgumentMatchers.*; | ||
| 30 | +import static org.mockito.Mockito.*; | ||
| 31 | + | ||
| 32 | +/** | ||
| 33 | + * {@link PayWalletRechargeController} 的方法级权限测试 | ||
| 34 | + * | ||
| 35 | + * 通过真实的 Spring Security 方法拦截({@link EnableMethodSecurity})+ 真实的 {@code @ss} 权限判断, | ||
| 36 | + * 校验发起钱包充值退款需要 pay:wallet:update-balance 权限,拒绝时不会调用 Service | ||
| 37 | + * | ||
| 38 | + * @author 芋道源码 | ||
| 39 | + */ | ||
| 40 | +@SpringJUnitConfig(PayWalletRechargeControllerSecurityTest.Config.class) | ||
| 41 | +public class PayWalletRechargeControllerSecurityTest { | ||
| 42 | + | ||
| 43 | + private static final String PERMISSION = "pay:wallet:update-balance"; | ||
| 44 | + private static final Long USER_ID = 1L; | ||
| 45 | + private static final Long RECHARGE_ID = 1024L; | ||
| 46 | + | ||
| 47 | + @Resource | ||
| 48 | + private PayWalletRechargeController controller; | ||
| 49 | + @Resource | ||
| 50 | + private PayWalletRechargeService walletRechargeService; | ||
| 51 | + @Resource | ||
| 52 | + private PermissionCommonApi permissionApi; | ||
| 53 | + | ||
| 54 | + @BeforeEach | ||
| 55 | + public void setUp() { | ||
| 56 | + reset(walletRechargeService, permissionApi); | ||
| 57 | + SecurityContextHolder.clearContext(); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + @AfterEach | ||
| 61 | + public void tearDown() { | ||
| 62 | + SecurityContextHolder.clearContext(); | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + @Test | ||
| 66 | + public void testControllerIsProxied() { | ||
| 67 | + // 确认测试走的是方法安全代理,而不是裸对象 | ||
| 68 | + assertTrue(AopUtils.isAopProxy(controller)); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + @Test | ||
| 72 | + public void testRefundWalletRecharge_hasPermission() { | ||
| 73 | + // mock 方法 | ||
| 74 | + loginAdmin(); | ||
| 75 | + when(permissionApi.hasAnyPermissions(eq(USER_ID), eq(PERMISSION))).thenReturn(true); | ||
| 76 | + | ||
| 77 | + // 调用 | ||
| 78 | + assertTrue(controller.refundWalletRecharge(RECHARGE_ID).getData()); | ||
| 79 | + // 断言 | ||
| 80 | + verify(permissionApi).hasAnyPermissions(eq(USER_ID), eq(PERMISSION)); | ||
| 81 | + verify(walletRechargeService).refundWalletRecharge(eq(RECHARGE_ID), isNull()); | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + @Test | ||
| 85 | + public void testRefundWalletRecharge_noPermission() { | ||
| 86 | + // mock 方法 | ||
| 87 | + loginAdmin(); | ||
| 88 | + when(permissionApi.hasAnyPermissions(anyLong(), any(String[].class))).thenReturn(false); | ||
| 89 | + | ||
| 90 | + // 调用,并断言 | ||
| 91 | + assertThrows(AccessDeniedException.class, () -> controller.refundWalletRecharge(RECHARGE_ID)); | ||
| 92 | + verify(permissionApi).hasAnyPermissions(eq(USER_ID), eq(PERMISSION)); | ||
| 93 | + verifyNoInteractions(walletRechargeService); | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + @Test | ||
| 97 | + public void testRefundWalletRecharge_anonymous() { | ||
| 98 | + // 未登录:Web 过滤链会放入匿名用户 | ||
| 99 | + SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken("key", "anonymousUser", | ||
| 100 | + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); | ||
| 101 | + | ||
| 102 | + // 调用,并断言 | ||
| 103 | + assertThrows(AccessDeniedException.class, () -> controller.refundWalletRecharge(RECHARGE_ID)); | ||
| 104 | + verifyNoInteractions(permissionApi, walletRechargeService); | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + @Test | ||
| 108 | + public void testRefundWalletRecharge_noAuthentication() { | ||
| 109 | + // 没有任何认证信息,同样拒绝:取决于 Spring Security 是否读取认证信息,抛出拒绝访问或认证缺失 | ||
| 110 | + Exception ex = assertThrows(Exception.class, () -> controller.refundWalletRecharge(RECHARGE_ID)); | ||
| 111 | + assertTrue(ex instanceof AccessDeniedException || ex instanceof AuthenticationCredentialsNotFoundException, | ||
| 112 | + "非预期的异常:" + ex); | ||
| 113 | + verifyNoInteractions(permissionApi, walletRechargeService); | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + @Test | ||
| 117 | + public void testUpdateWalletRechargerPaid_callbackNotRestricted() { | ||
| 118 | + // 支付回调:仍由 @PermitAll + 内部校验,不受后台权限约束 | ||
| 119 | + PayOrderNotifyReqDTO reqDTO = PayOrderNotifyReqDTO.builder().merchantOrderId("1").payOrderId(2L).build(); | ||
| 120 | + | ||
| 121 | + // 调用 | ||
| 122 | + assertTrue(controller.updateWalletRechargerPaid(reqDTO).getData()); | ||
| 123 | + // 断言 | ||
| 124 | + verify(walletRechargeService).updateWalletRechargerPaid(eq(1L), eq(2L)); | ||
| 125 | + verifyNoInteractions(permissionApi); | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + private static void loginAdmin() { | ||
| 129 | + LoginUser loginUser = new LoginUser(); | ||
| 130 | + loginUser.setId(USER_ID); | ||
| 131 | + loginUser.setUserType(UserTypeEnum.ADMIN.getValue()); | ||
| 132 | + loginUser.setTenantId(1L); | ||
| 133 | + SecurityContextHolder.getContext().setAuthentication( | ||
| 134 | + new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList())); | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + @Configuration | ||
| 138 | + @EnableMethodSecurity(securedEnabled = true) | ||
| 139 | + public static class Config { | ||
| 140 | + | ||
| 141 | + @Bean | ||
| 142 | + public PayWalletRechargeController payWalletRechargeController() { | ||
| 143 | + return new PayWalletRechargeController(); | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + @Bean | ||
| 147 | + public PayWalletRechargeService payWalletRechargeService() { | ||
| 148 | + return mock(PayWalletRechargeService.class); | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + @Bean | ||
| 152 | + public PermissionCommonApi permissionCommonApi() { | ||
| 153 | + return mock(PermissionCommonApi.class); | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + @Bean("ss") | ||
| 157 | + public SecurityFrameworkService securityFrameworkService(PermissionCommonApi permissionApi) { | ||
| 158 | + return new SecurityFrameworkServiceImpl(permissionApi); | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | +} |