You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何为Spring Cloud Reactive API Gateway自定义过滤器编写单元测试验证公共端点放行

Spring Cloud Gateway AuthAccountFilter 单元测试指导

一、先修复过滤器本身的逻辑缺陷

原AuthAccountFilter存在两处关键问题:

  • 安全端点分支无返回值,会触发编译错误
  • 公共端点判断逻辑冗余,可优化为集合维护
  • 依赖注入方式不利于测试,建议改用构造注入

修复后的过滤器代码:

package gateway.filters;

import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.shaded.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono;

import java.text.ParseException;
import java.util.Set;

@Component
public class AuthAccountFilter extends AbstractGatewayFilterFactory<AuthAccountFilter.Config> {

    private static final Logger LOGGER = LoggerFactory.getLogger(AuthAccountFilter.class);
    private static final Set<String> PUBLIC_ENDPOINT_KEYWORDS = Set.of("auth", "otp", "reset-password");

    private final WebClient webClient;

    // 构造注入替代字段注入,便于测试时替换依赖
    @Autowired
    public AuthAccountFilter(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.build();
    }

    @Override
    public Class<Config> getConfigClass() {
        return Config.class;
    }

    public static class Config {
        // 空配置类,用于适配GatewayFilterFactory规范
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            String endpoint = exchange.getRequest().getPath().toString().toLowerCase();

            LOGGER.trace("处理端点: {}", endpoint);
            LOGGER.info("检查端点权限: {}", endpoint);

            // 判断是否为公共端点
            boolean isPublic = PUBLIC_ENDPOINT_KEYWORDS.stream().anyMatch(endpoint::contains);
            if (isPublic) {
                LOGGER.info("公共端点,直接放行");
                return chain.filter(exchange);
            }

            // 安全端点:校验JWT令牌
            String jwtToken = exchange.getRequest().getHeaders().getFirst("Authorization");
            if (jwtToken == null || !jwtToken.startsWith("Bearer ")) {
                LOGGER.warn("安全端点缺少有效JWT令牌");
                return Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "未提供有效认证令牌"));
            }

            // 调用JWT验证逻辑,验证通过则放行
            return validateJwtToken(jwtToken.substring(7))
                    .flatMap(valid -> {
                        if (valid) {
                            LOGGER.info("JWT验证通过,放行请求");
                            return chain.filter(exchange);
                        } else {
                            return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "认证令牌无效"));
                        }
                    });
        };
    }

    // 提取JWT验证方法,方便单独测试
    private Mono<Boolean> validateJwtToken(String token) {
        try {
            JWSObject jwsObject = JWSObject.parse(token);
            JSONObject payload = jwsObject.getPayload().toJSONObject();
            // 简单验证过期时间(实际场景可替换为调用认证服务校验)
            long exp = (long) payload.get("exp");
            return Mono.just(exp > System.currentTimeMillis() / 1000);
        } catch (ParseException e) {
            LOGGER.error("解析JWT失败", e);
            return Mono.just(false);
        }
    }
}

二、编写正确的单元测试

单元测试需覆盖公共端点放行、无JWT返回401、无效JWT返回403、有效JWT放行四个核心场景,使用WebFlux标准测试工具StepVerifier验证响应式流行为:

package gateway.filters;

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.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class AuthAccountFilterTest {

    @Mock
    private GatewayFilterChain filterChain;

    @InjectMocks
    private AuthAccountFilter authAccountFilter;

    // 测试公共端点直接放行
    @Test
    void testPublicEndpoint_ShouldPassThrough() {
        MockServerHttpRequest request = MockServerHttpRequest.get("/my-service/auth/login")
                .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);

        when(filterChain.filter(exchange)).thenReturn(Mono.empty());

        AuthAccountFilter.Config config = new AuthAccountFilter.Config();
        GatewayFilter filter = authAccountFilter.apply(config);
        Mono<Void> result = filter.filter(exchange, filterChain);

        StepVerifier.create(result)
                .verifyComplete();
        verify(filterChain, times(1)).filter(exchange);
    }

    // 测试安全端点无JWT返回401
    @Test
    void testSecureEndpoint_NoJwt_ShouldReturn401() {
        MockServerHttpRequest request = MockServerHttpRequest.get("/my-service/user/profile")
                .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);

        AuthAccountFilter.Config config = new AuthAccountFilter.Config();
        GatewayFilter filter = authAccountFilter.apply(config);
        Mono<Void> result = filter.filter(exchange, filterChain);

        StepVerifier.create(result)
                .expectErrorMatches(throwable -> throwable instanceof org.springframework.web.server.ResponseStatusException
                        && ((org.springframework.web.server.ResponseStatusException) throwable).getStatus().value() == 401)
                .verify();
        verify(filterChain, never()).filter(exchange);
    }

    // 测试安全端点无效JWT返回403
    @Test
    void testSecureEndpoint_InvalidJwt_ShouldReturn403() {
        MockServerHttpRequest request = MockServerHttpRequest.get("/my-service/user/profile")
                .header(HttpHeaders.AUTHORIZATION, "Bearer invalid-token")
                .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);

        AuthAccountFilter.Config config = new AuthAccountFilter.Config();
        GatewayFilter filter = authAccountFilter.apply(config);
        Mono<Void> result = filter.filter(exchange, filterChain);

        StepVerifier.create(result)
                .expectErrorMatches(throwable -> throwable instanceof org.springframework.web.server.ResponseStatusException
                        && ((org.springframework.web.server.ResponseStatusException) throwable).getStatus().value() == 403)
                .verify();
        verify(filterChain, never()).filter(exchange);
    }

    // 测试安全端点有效JWT放行
    @Test
    void testSecureEndpoint_ValidJwt_ShouldPassThrough() {
        // 生成过期时间为未来的有效JWT
        String validJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE5MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

        MockServerHttpRequest request = MockServerHttpRequest.get("/my-service/user/profile")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + validJwt)
                .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);

        when(filterChain.filter(exchange)).thenReturn(Mono.empty());

        AuthAccountFilter.Config config = new AuthAccountFilter.Config();
        GatewayFilter filter = authAccountFilter.apply(config);
        Mono<Void> result = filter.filter(exchange, filterChain);

        StepVerifier.create(result)
                .verifyComplete();
        verify(filterChain, times(1)).filter(exchange);
    }
}

三、测试核心要点

  • 使用MockitoExtension适配JUnit 5,替代旧版SpringRunner
  • 用StepVerifier验证响应式流的完成、错误状态,这是WebFlux测试的标准方式
  • 每个测试用例仅验证单一场景,保持测试逻辑清晰
  • 构造注入依赖,便于测试时替换模拟实现(如需验证WebClient调用,可替换为WebClient的Mock实例)

内容的提问来源于stack exchange,提问作者Amine Ch 99

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.06 06:20:40