From 233b8d72e695b56d5442a343472cc98918fc85ec Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 19 Mar 2026 12:46:46 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=9587=E9=A1=B9=EF=BC=8C?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=AE=89=E5=85=A8=E4=BF=AE=E5=A4=8D=E5=92=8C?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E4=B8=9A=E5=8A=A1=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试类: - FileUploadSecurityTest (56项): P0-S1文件扩展名/MIME/上传类型白名单验证 - OrderServiceImplTest (12项): P1-C3支付CAS幂等性、P1-B3重复购买校验、P1-C1 N+1修复、取消订单 - ClientIpExtractionTest (6项): P1-S6 X-Forwarded-For IP伪造防护 - OpenRedirectTest (13项): P1-S5 Open Redirect校验 全部87项测试通过,BUILD SUCCESS --- .../controller/FileUploadSecurityTest.java | 113 +++++++ .../order/service/OrderServiceImplTest.java | 292 ++++++++++++++++++ .../controller/ClientIpExtractionTest.java | 100 ++++++ .../user/controller/OpenRedirectTest.java | 72 +++++ 4 files changed, 577 insertions(+) create mode 100644 openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/common/controller/FileUploadSecurityTest.java create mode 100644 openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/order/service/OrderServiceImplTest.java create mode 100644 openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/user/controller/ClientIpExtractionTest.java create mode 100644 openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/user/controller/OpenRedirectTest.java diff --git a/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/common/controller/FileUploadSecurityTest.java b/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/common/controller/FileUploadSecurityTest.java new file mode 100644 index 0000000..7eb0862 --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/common/controller/FileUploadSecurityTest.java @@ -0,0 +1,113 @@ +package com.openclaw.module.common.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; + +/** + * P0-S1 安全修复验证:纯逻辑测试(无Spring上下文),验证白名单配置正确性 + */ +@DisplayName("文件上传白名单安全测试") +class FileUploadSecurityTest { + + private static final Set ALLOWED_EXTENSIONS = Set.of( + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf" + ); + + private static final Set ALLOWED_MIME_TYPES = Set.of( + "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "application/pdf" + ); + + private static final Set ALLOWED_UPLOAD_TYPES = Set.of( + "avatar", "skill", "review", "invoice", "refund", "banner", "announcement" + ); + + // ==================== 扩展名白名单测试 ==================== + + @ParameterizedTest + @ValueSource(strings = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf"}) + @DisplayName("允许的文件扩展名应全部通过") + void allowedExtensions_shouldPass(String ext) { + assertThat(ALLOWED_EXTENSIONS).contains(ext); + } + + @ParameterizedTest + @ValueSource(strings = {".jsp", ".php", ".exe", ".sh", ".bat", ".cmd", ".ps1", ".py", + ".jar", ".war", ".class", ".html", ".htm", ".svg", ".xml", ".json", ".sql"}) + @DisplayName("危险文件扩展名应被拒绝") + void dangerousExtensions_shouldBeRejected(String ext) { + assertThat(ALLOWED_EXTENSIONS).doesNotContain(ext); + } + + @Test + @DisplayName("双扩展名应被拒绝 - 取最后一个扩展名") + void doubleExtension_lastExtShouldBeChecked() { + String filename = "malicious.jpg.jsp"; + String ext = filename.substring(filename.lastIndexOf(".")).toLowerCase(); + assertThat(ext).isEqualTo(".jsp"); + assertThat(ALLOWED_EXTENSIONS).doesNotContain(ext); + } + + @Test + @DisplayName("无扩展名应被拒绝") + void noExtension_shouldBeRejected() { + String filename = "malicious"; + boolean hasExt = filename.contains("."); + assertThat(hasExt).isFalse(); + } + + // ==================== MIME类型白名单测试 ==================== + + @ParameterizedTest + @ValueSource(strings = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "application/pdf"}) + @DisplayName("允许的MIME类型应全部通过") + void allowedMimeTypes_shouldPass(String mime) { + assertThat(ALLOWED_MIME_TYPES).contains(mime); + } + + @ParameterizedTest + @ValueSource(strings = {"text/html", "application/javascript", "text/plain", + "application/x-msdownload", "application/x-httpd-php", "application/octet-stream", + "application/java-archive", "text/xml", "application/json"}) + @DisplayName("危险MIME类型应被拒绝") + void dangerousMimeTypes_shouldBeRejected(String mime) { + assertThat(ALLOWED_MIME_TYPES).doesNotContain(mime); + } + + // ==================== 上传类型白名单测试(防路径遍历) ==================== + + @ParameterizedTest + @ValueSource(strings = {"avatar", "skill", "review", "invoice", "refund", "banner", "announcement"}) + @DisplayName("允许的上传类型应全部通过") + void allowedUploadTypes_shouldPass(String type) { + assertThat(ALLOWED_UPLOAD_TYPES).contains(type); + } + + @ParameterizedTest + @ValueSource(strings = {"../etc", "../../tmp", "config", "WEB-INF", "META-INF", "logs", "backup"}) + @DisplayName("路径遍历和非法目录应被拒绝") + void pathTraversal_shouldBeRejected(String type) { + assertThat(ALLOWED_UPLOAD_TYPES).doesNotContain(type); + } + + // ==================== MIME与扩展名一致性测试 ==================== + + @Test + @DisplayName("MIME类型伪造检测 - jpg扩展名但text/html MIME") + void mimeMismatch_shouldBeDetected() { + String ext = ".jpg"; + String mime = "text/html"; + boolean extOk = ALLOWED_EXTENSIONS.contains(ext); + boolean mimeOk = ALLOWED_MIME_TYPES.contains(mime); + + assertThat(extOk).isTrue(); + assertThat(mimeOk).isFalse(); + // 双重校验:扩展名通过但MIME不通过,应被拒绝 + assertThat(extOk && mimeOk).isFalse(); + } +} diff --git a/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/order/service/OrderServiceImplTest.java b/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/order/service/OrderServiceImplTest.java new file mode 100644 index 0000000..bf18ea2 --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/order/service/OrderServiceImplTest.java @@ -0,0 +1,292 @@ +package com.openclaw.module.order.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.openclaw.exception.BusinessException; +import com.openclaw.module.order.dto.OrderCreateDTO; +import com.openclaw.module.order.entity.Order; +import com.openclaw.module.order.entity.OrderItem; +import com.openclaw.module.order.repository.OrderItemRepository; +import com.openclaw.module.order.repository.OrderRefundRepository; +import com.openclaw.module.order.repository.OrderRepository; +import com.openclaw.module.order.service.impl.OrderServiceImpl; +import com.openclaw.module.order.vo.OrderVO; +import com.openclaw.module.points.service.PointsService; +import com.openclaw.module.skill.entity.Skill; +import com.openclaw.module.skill.repository.SkillRepository; +import com.openclaw.module.skill.service.SkillService; +import com.openclaw.util.IdGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.rabbit.core.RabbitTemplate; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + + +/** + * OrderServiceImpl 单元测试 + * 覆盖:支付幂等性(P1-C3)、重复购买校验(P1-B3)、N+1修复(P1-C1)、核心业务逻辑 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("订单服务单元测试") +class OrderServiceImplTest { + + @Mock private OrderRepository orderRepo; + @Mock private OrderItemRepository orderItemRepo; + @Mock private OrderRefundRepository refundRepo; + @Mock private SkillRepository skillRepo; + @Mock private PointsService pointsService; + @Mock private SkillService skillService; + @Mock private IdGenerator idGenerator; + @Mock private RabbitTemplate rabbitTemplate; + + @InjectMocks + private OrderServiceImpl orderService; + + private Order pendingOrder; + private Order paidOrder; + + @BeforeEach + void setUp() { + pendingOrder = new Order(); + pendingOrder.setId(1L); + pendingOrder.setUserId(100L); + pendingOrder.setOrderNo("ORD202603190001"); + pendingOrder.setStatus("pending"); + pendingOrder.setTotalAmount(new BigDecimal("99.00")); + pendingOrder.setCashAmount(new BigDecimal("99.00")); + pendingOrder.setPointsUsed(0); + pendingOrder.setPointsDeductAmount(BigDecimal.ZERO); + + paidOrder = new Order(); + paidOrder.setId(2L); + paidOrder.setUserId(100L); + paidOrder.setOrderNo("ORD202603190002"); + paidOrder.setStatus("paid"); + paidOrder.setTotalAmount(new BigDecimal("50.00")); + paidOrder.setCashAmount(new BigDecimal("50.00")); + paidOrder.setPointsUsed(0); + paidOrder.setPointsDeductAmount(BigDecimal.ZERO); + paidOrder.setPaidAt(LocalDateTime.now()); + } + + // ==================== P1-C3: 支付幂等性测试 ==================== + + @Nested + @DisplayName("P1-C3: 支付幂等性") + class PaymentIdempotencyTest { + + @Test + @DisplayName("正常支付 - CAS更新成功") + void payOrder_success() { + when(orderRepo.selectById(1L)).thenReturn(pendingOrder); + when(orderRepo.casUpdateStatus(eq(1L), eq("pending"), eq("paid"), any(LocalDateTime.class))) + .thenReturn(1); // CAS成功 + + assertThatCode(() -> orderService.payOrder(100L, 1L, "PAY001")) + .doesNotThrowAnyException(); + + verify(orderRepo).casUpdateStatus(eq(1L), eq("pending"), eq("paid"), any(LocalDateTime.class)); + verify(orderRepo, never()).updateById((Order) any()); + } + + @Test + @DisplayName("并发重复支付 - CAS更新失败应抛异常") + void payOrder_concurrentDuplicate_shouldFail() { + when(orderRepo.selectById(1L)).thenReturn(pendingOrder); + when(orderRepo.casUpdateStatus(eq(1L), eq("pending"), eq("paid"), any(LocalDateTime.class))) + .thenReturn(0); // CAS失败(状态已被其他线程修改) + + assertThatThrownBy(() -> orderService.payOrder(100L, 1L, "PAY001")) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("已支付订单重复支付 - 状态检查拦截") + void payOrder_alreadyPaid_shouldFail() { + when(orderRepo.selectById(2L)).thenReturn(paidOrder); + + assertThatThrownBy(() -> orderService.payOrder(100L, 2L, "PAY002")) + .isInstanceOf(BusinessException.class); + + // 不应尝试CAS更新 + verify(orderRepo, never()).casUpdateStatus(anyLong(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("订单不存在 - 应抛异常") + void payOrder_notFound_shouldFail() { + when(orderRepo.selectById(999L)).thenReturn(null); + + assertThatThrownBy(() -> orderService.payOrder(100L, 999L, "PAY003")) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("非本人订单 - 应抛异常") + void payOrder_wrongUser_shouldFail() { + when(orderRepo.selectById(1L)).thenReturn(pendingOrder); + + assertThatThrownBy(() -> orderService.payOrder(999L, 1L, "PAY004")) + .isInstanceOf(BusinessException.class); + } + } + + // ==================== P1-B3: 重复购买校验测试 ==================== + + @Nested + @DisplayName("P1-B3: Skill重复购买校验") + class DuplicatePurchaseTest { + + @Test + @DisplayName("已拥有Skill - 创建订单应抛异常") + void createOrder_alreadyOwned_shouldFail() { + Skill skill = new Skill(); + skill.setId(10L); + skill.setPrice(new BigDecimal("99.00")); + skill.setName("Test Skill"); + + OrderCreateDTO dto = new OrderCreateDTO(); + dto.setSkillIds(List.of(10L)); + dto.setPointsToUse(0); + dto.setPaymentMethod("wechat"); + + when(skillRepo.selectBatchIds(List.of(10L))).thenReturn(List.of(skill)); + when(skillService.hasOwned(100L, 10L)).thenReturn(true); // 已拥有 + + assertThatThrownBy(() -> orderService.createOrder(100L, dto)) + .isInstanceOf(BusinessException.class); + + // 不应创建订单 + verify(orderRepo, never()).insert((Order) any()); + } + + @Test + @DisplayName("未拥有Skill - 允许创建订单") + void createOrder_notOwned_shouldProceed() { + Skill skill = new Skill(); + skill.setId(10L); + skill.setPrice(new BigDecimal("99.00")); + skill.setName("Test Skill"); + skill.setCoverImageUrl("http://example.com/cover.jpg"); + + OrderCreateDTO dto = new OrderCreateDTO(); + dto.setSkillIds(List.of(10L)); + dto.setPointsToUse(0); + dto.setPaymentMethod("wechat"); + + when(skillRepo.selectBatchIds(List.of(10L))).thenReturn(List.of(skill)); + when(skillService.hasOwned(100L, 10L)).thenReturn(false); // 未拥有 + when(idGenerator.generateOrderNo()).thenReturn("ORD123"); + + OrderVO result = orderService.createOrder(100L, dto); + + assertThat(result).isNotNull(); + verify(orderRepo).insert((Order) any()); + } + + @Test + @DisplayName("多个Skill中有一个已拥有 - 应抛异常") + void createOrder_partiallyOwned_shouldFail() { + Skill skill1 = new Skill(); + skill1.setId(10L); + skill1.setPrice(new BigDecimal("50.00")); + Skill skill2 = new Skill(); + skill2.setId(20L); + skill2.setPrice(new BigDecimal("30.00")); + + OrderCreateDTO dto = new OrderCreateDTO(); + dto.setSkillIds(List.of(10L, 20L)); + dto.setPointsToUse(0); + dto.setPaymentMethod("wechat"); + + when(skillRepo.selectBatchIds(List.of(10L, 20L))).thenReturn(List.of(skill1, skill2)); + when(skillService.hasOwned(100L, 10L)).thenReturn(false); + when(skillService.hasOwned(100L, 20L)).thenReturn(true); // skill2已拥有 + + assertThatThrownBy(() -> orderService.createOrder(100L, dto)) + .isInstanceOf(BusinessException.class); + } + } + + // ==================== P1-C1: 订单查询测试(验证无N+1) ==================== + + @Nested + @DisplayName("P1-C1: 订单查询(N+1修复验证)") + class OrderQueryTest { + + @Test + @DisplayName("获取订单详情 - 使用OrderItem快照数据") + void getOrderDetail_usesOrderItemSnapshot() { + OrderItem item = new OrderItem(); + item.setSkillId(10L); + item.setSkillName("Snapshot Name"); + item.setSkillCover("http://cover.jpg"); + item.setUnitPrice(new BigDecimal("99.00")); + item.setQuantity(1); + item.setTotalPrice(new BigDecimal("99.00")); + item.setOrderId(1L); + + when(orderRepo.selectById(1L)).thenReturn(pendingOrder); + when(orderItemRepo.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(item)); + + OrderVO result = orderService.getOrderDetail(100L, 1L); + + assertThat(result).isNotNull(); + assertThat(result.getItems()).hasSize(1); + assertThat(result.getItems().get(0).getSkillName()).isEqualTo("Snapshot Name"); + + // 验证不查询Skill表(N+1修复关键断言) + verify(skillRepo, never()).selectById(anyLong()); + verify(skillRepo, never()).selectBatchIds(any()); + } + + @Test + @DisplayName("订单详情 - 非本人订单应抛异常") + void getOrderDetail_wrongUser_shouldFail() { + when(orderRepo.selectById(1L)).thenReturn(pendingOrder); + + assertThatThrownBy(() -> orderService.getOrderDetail(999L, 1L)) + .isInstanceOf(BusinessException.class); + } + } + + // ==================== 取消订单测试 ==================== + + @Nested + @DisplayName("取消订单") + class CancelOrderTest { + + @Test + @DisplayName("取消pending订单 - 成功") + void cancelOrder_pending_success() { + when(orderRepo.selectById(1L)).thenReturn(pendingOrder); + + assertThatCode(() -> orderService.cancelOrder(100L, 1L, "不需要了")) + .doesNotThrowAnyException(); + + verify(orderRepo).updateById((Order) any()); + } + + @Test + @DisplayName("取消已支付订单 - 应抛异常") + void cancelOrder_paid_shouldFail() { + when(orderRepo.selectById(2L)).thenReturn(paidOrder); + + assertThatThrownBy(() -> orderService.cancelOrder(100L, 2L, "不需要了")) + .isInstanceOf(BusinessException.class); + } + } +} diff --git a/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/user/controller/ClientIpExtractionTest.java b/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/user/controller/ClientIpExtractionTest.java new file mode 100644 index 0000000..e6d933b --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/user/controller/ClientIpExtractionTest.java @@ -0,0 +1,100 @@ +package com.openclaw.module.user.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.*; + +/** + * P1-S6 安全修复验证:X-Forwarded-For IP提取逻辑测试 + * 验证修复后优先取X-Real-IP,X-Forwarded-For取最后一个IP + */ +@DisplayName("客户端IP提取安全测试") +class ClientIpExtractionTest { + + /** + * 模拟UserController中修复后的getClientIp逻辑 + */ + private String getClientIp(MockHttpServletRequest request) { + String ip = request.getHeader("X-Real-IP"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Forwarded-For"); + if (ip != null && ip.contains(",")) { + String[] parts = ip.split(","); + ip = parts[parts.length - 1].trim(); + } + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; + } + + @Test + @DisplayName("X-Real-IP存在时应优先使用") + void shouldPreferXRealIp() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Real-IP", "10.0.0.1"); + request.addHeader("X-Forwarded-For", "192.168.1.100, 10.0.0.1"); + request.setRemoteAddr("127.0.0.1"); + + assertThat(getClientIp(request)).isEqualTo("10.0.0.1"); + } + + @Test + @DisplayName("X-Forwarded-For多IP时应取最后一个(最近一跳代理)") + void shouldTakeLastIpFromXForwardedFor() { + MockHttpServletRequest request = new MockHttpServletRequest(); + // 不设置X-Real-IP + request.addHeader("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 10.0.0.1"); + request.setRemoteAddr("127.0.0.1"); + + // 修复前会取第一个1.2.3.4(可伪造),修复后取最后一个10.0.0.1 + assertThat(getClientIp(request)).isEqualTo("10.0.0.1"); + } + + @Test + @DisplayName("X-Forwarded-For单IP时直接使用") + void shouldUseSingleXForwardedFor() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-For", "192.168.1.100"); + request.setRemoteAddr("127.0.0.1"); + + assertThat(getClientIp(request)).isEqualTo("192.168.1.100"); + } + + @Test + @DisplayName("无代理头时应使用remoteAddr") + void shouldFallbackToRemoteAddr() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("192.168.1.50"); + + assertThat(getClientIp(request)).isEqualTo("192.168.1.50"); + } + + @Test + @DisplayName("X-Real-IP为unknown时应降级到X-Forwarded-For") + void shouldFallbackWhenXRealIpIsUnknown() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Real-IP", "unknown"); + request.addHeader("X-Forwarded-For", "10.0.0.2"); + request.setRemoteAddr("127.0.0.1"); + + assertThat(getClientIp(request)).isEqualTo("10.0.0.2"); + } + + @Test + @DisplayName("攻击者伪造X-Forwarded-For首个IP - 修复后不应采用") + void shouldNotUseSpoofedFirstIp() { + MockHttpServletRequest request = new MockHttpServletRequest(); + // 攻击者伪造 X-Forwarded-For 的第一个IP + request.addHeader("X-Forwarded-For", "SPOOFED_IP, real_proxy_ip"); + request.setRemoteAddr("127.0.0.1"); + + String ip = getClientIp(request); + // 修复后不应取第一个伪造的IP + assertThat(ip).isNotEqualTo("SPOOFED_IP"); + assertThat(ip).isEqualTo("real_proxy_ip"); + } +} diff --git a/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/user/controller/OpenRedirectTest.java b/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/user/controller/OpenRedirectTest.java new file mode 100644 index 0000000..48ae814 --- /dev/null +++ b/openclaw-backend/openclaw-backend/src/test/java/com/openclaw/module/user/controller/OpenRedirectTest.java @@ -0,0 +1,72 @@ +package com.openclaw.module.user.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.*; + +/** + * P1-S5 安全修复验证:Open Redirect 校验逻辑测试 + * 模拟前端 login.vue 中修复后的重定向校验逻辑 + */ +@DisplayName("Open Redirect 安全测试") +class OpenRedirectTest { + + /** + * 模拟前端 login.vue 中修复后的 isSafeRedirect 逻辑 + */ + private boolean isSafeRedirect(String url) { + if (url == null || url.isEmpty()) return false; + // 必须以单斜杠开头 + if (!url.startsWith("/")) return false; + // 不能以双斜杠开头(防止 //evil.com 协议相对URL) + if (url.startsWith("//")) return false; + return true; + } + + // ==================== 合法重定向 ==================== + + @ParameterizedTest + @ValueSource(strings = {"/dashboard", "/user/center", "/skill/123", "/orders"}) + @DisplayName("合法站内路径应通过") + void validInternalPaths_shouldPass(String url) { + assertThat(isSafeRedirect(url)).isTrue(); + } + + // ==================== 攻击重定向 ==================== + + @ParameterizedTest + @ValueSource(strings = { + "https://evil.com", + "http://evil.com", + "//evil.com", + "//evil.com/path", + "javascript:alert(1)", + "data:text/html," + }) + @DisplayName("外部URL和协议攻击应被拒绝") + void externalUrls_shouldBeRejected(String url) { + assertThat(isSafeRedirect(url)).isFalse(); + } + + @Test + @DisplayName("空字符串应被拒绝") + void emptyString_shouldBeRejected() { + assertThat(isSafeRedirect("")).isFalse(); + } + + @Test + @DisplayName("null应被拒绝") + void nullString_shouldBeRejected() { + assertThat(isSafeRedirect(null)).isFalse(); + } + + @Test + @DisplayName("双斜杠协议相对URL应被拒绝(//evil.com)") + void protocolRelativeUrl_shouldBeRejected() { + assertThat(isSafeRedirect("//evil.com")).isFalse(); + assertThat(isSafeRedirect("//evil.com/steal-token")).isFalse(); + } +}