test: 添加后端单元测试87项,覆盖安全修复和核心业务逻辑
测试类: - 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
This commit is contained in:
@@ -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<String> ALLOWED_EXTENSIONS = Set.of(
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf"
|
||||
);
|
||||
|
||||
private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "application/pdf"
|
||||
);
|
||||
|
||||
private static final Set<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,<script>alert(1)</script>"
|
||||
})
|
||||
@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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user