我正在尝试为微服务架构中的 Spring Cloud Reactive API Gateway 的 authAccountFilter 编写单元测试。网关使用 WebFlux,authAccountFilter 负责检查端点是公共的还是安全的。如果是公共端点,则允许请求通过。但是,如果它是安全端点,则 authAccountFilter 会在允许请求通过之前检查 JWT 标头。
我尝试了多种方法和实现,但我无法让我的单元测试通过。我怀疑问题可能出在我的实现上,但我不确定。
如果有人可以提供一些关于如何在微服务架构中使用 WebFlux 为 Spring Cloud Reactive API Gateway 正确编写此单元测试的指导或想法,我将不胜感激。
GatewayApplication.java:
package gateway;
import gateway.filters.*; // simplified the import all the filter once (AuthAccountFilter included)
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.hystrix.EnableHystrix;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.server.handler.DefaultWebFilterChain;
@SpringBootApplication(exclude = { ErrorMvcAutoConfiguration.class })
@EnableEurekaClient
@CrossOrigin(origins = "*", allowedHeaders = "*")
@EnableDiscoveryClient
@EnableHystrix
public class GatewayApplication implements CommandLineRunner {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@Bean
public RouteLocator routeLocator(RouteLocatorBuilder rlb, AuthAccountFilter authAccountFilter) {
return rlb
.routes()
.route(p -> p
.path("/my-service/**")
.filters(f -> f
.rewritePath("/my-service/(?<segment>.*)", "/$\\{segment}")
.filter(authAccountFilter.apply(new AuthAccountFilter.Config())))
.uri("lb://MY-SERVICE"))
.build();
}
@Override
public void run(String... args) throws Exception {
System.out.println("... My-Service is UP -- READY TO GO!");
}
}
AuthAccountFilter.java:
package gateway.filters;
import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.shaded.json.JSONObject;
import com.nimbusds.jwt.JWTClaimsSet;
import org.apache.http.entity.ContentType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
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.ArrayList;
import java.util.Date;
@Component
public class AuthAccountFilter extends AbstractGatewayFilterFactory<AuthAccountFilter.Config> {
private Logger LOGGER = LoggerFactory.getLogger(AuthAccountFilter.class);
@Autowired
WebClient.Builder webClientBuilder;
@Override
public Class<Config> getConfigClass() {
return Config.class;
}
public static class Config {
// empty class as I don't need any particular configuration
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String endpoint = exchange.getRequest().getPath().toString();
LOGGER.trace("Gateway filter for endpoint : " + endpoint);
LOGGER.info("Checking permission for endpoint : " + endpoint);
if (exchange.getRequest().getPath().toString().contains("auth") ||
exchange.getRequest().getPath().toString().contains("otp") ||
exchange.getRequest().getPath().toString().toLowerCase().contains("reset-password")) {
LOGGER.info("Public endpoint, aborting filter");
Mono<Void> filter = chain.filter(exchange);
System.err.println(filter == null);
return filter;
}
};
}
}
AuthAccountFilterTest.java:
package gateway.filters;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.CoreSubscriber;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import static org.junit.Assert.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@RunWith(SpringRunner.class)
class AuthAccountFilterTest {
private GatewayFilterChain filterChain = mock(GatewayFilterChain.class);
@Test
void testPublicEndpoint() {
String baseUrl = "http://localhost:9090/my-service/";
// Create a mock request and response
MockServerHttpRequest request = MockServerHttpRequest.get(baseUrl + "auth").build();
MockServerHttpResponse response = new MockServerHttpResponse();
// Create an instance of your AuthFilter and any dependencies it has
AuthAccountFilter filter = new AuthAccountFilter();
WebFilterChain chain = (exchange, filterChain) -> {
// Set the Config instance on the Exchange object
AuthAccountFilter.Config config = new AuthAccountFilter.Config();
exchange.getAttributes().put("config", config);
// Call the apply method of the AuthFilter, passing in the Config instance
return filter.apply(config);
};
}
}
提前感谢您的帮助。
测试网关过滤器的最佳方法可能是使用
WebTestClient
创建集成 Spring Boot 测试。它将允许验证端到端请求处理并确保所有配置都正确定义。
为了做到这一点,您需要通过将下游服务 URI 提取到配置属性中来使您的路由可测试。
@Bean
public RouteLocator routeLocator(RouteLocatorBuilder rlb, AuthAccountFilter authAccountFilter) {
return rlb
.routes()
.route(p -> p.path("/my-service/*")
.filters(f -> f
.rewritePath("/my-service/(?<segment>.*)", "/$\\{segment}")
.filter(authAccountFilter.apply(new AuthAccountFilter.Config())))
.uri(properties.getServiceUri()))
.build();
}
此外,您还可以使用 WireMock 来模拟下游服务并验证路由是否正确定义。
SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureWireMock(port = 0) // random port
@AutoConfigureWebTestClient
class GatewayConfigurationTest {
@Autowired
private GatewayProperties gatewayProperties;
@Autowired
private WebTestClient webTestClient;
@Test
void verifyAuthRequest() {
// mock downstream service
stubFor(get(urlPathMatching("/auth"))
.willReturn(aResponse()
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withStatus(200)
)
);
// make request to gateway
webTestClient
.get()
.uri("/my-service/auth")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk();
verify(1, getRequestedFor(urlEqualTo("/auth")));
}
@TestConfiguration
static class TestGatewayConfiguration {
public TestGatewayConfiguration(
@Value("${wiremock.server.port}") int wireMockPort,
GatewayProperties properties) {
properties.setServiceUri("http://localhost:" + wireMockPort);
}
}
}
此测试依赖于
AutoConfigureWireMock
,您需要将测试依赖添加到org.springframework.cloud:spring-cloud-contract-wiremock
。作为替代方案,您可以添加对 WireMock 的直接依赖项并显式初始化它。
您仍然可以使用模拟请求单独使用单元测试来测试您的过滤器,但在您的情况下,它提供的投资回报率非常低
@Test
void filterTest() {
var filterFactory = new AuthAccountFilter();
MockServerHttpRequest request = MockServerHttpRequest.get("/my-service/auth").build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
var filter = filterFactory.apply(new AuthAccountFilter.Config());
GatewayFilterChain filterChain = mock(GatewayFilterChain.class);
ArgumentCaptor<ServerWebExchange> captor = ArgumentCaptor.forClass(ServerWebExchange.class);
when(filterChain.filter(captor.capture())).thenReturn(Mono.empty());
StepVerifier.create(filter.filter(exchange, filterChain))
.verifyComplete();
var resultExchange = captor.getValue();
// verify result exchange
}
ps
看看 Spring Security,它允许您使用
SecurityWebFilterChain
定义相同的规则 https://docs.spring.io/spring-security/reference/reactive/configuration/webflux.html.
@Bean
public SecurityWebFilterChain apiSecurity(ServerHttpSecurity http) {
http.authorizeExchange()
.pathMatchers("/my-service/auth").permitAll()
.pathMatchers("/my-service/otp").permitAll()
.pathMatchers("/my-service/**/reset-password").permitAll()
.anyExchange().authenticated()
.and()
.oauth2ResourceServer()
.jwt();
return http.build();
}
我提出这个问题是因为我在 Java 上测试 Webflux (SpringBoot) 应用程序的 WebFilter 时面临着同样的挑战。
我想出了通过使用 Spring Cloud Gateway 属性来测试这种场景:
private static MockWebServer mockExternalBackEnd;
private static ObjectMapper objectMapper;
@Autowired
WebTestClient webTestClient;
MyFilter myFilter;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
private static class StubResponse {
private String status;
}
@BeforeAll
static void setUp() throws IOException {
objectMapper = createObjectMapper();
mockExternalBackEnd = new MockWebServer();
mockExternalBackEnd.start();
}
@AfterAll
static void tearDown() throws IOException {
mockExternalBackEnd.shutdown();
}
@BeforeEach
public void init() throws JsonProcessingException {
this.myFilter = new MyFilter();
// External service to be redirected answers "OK"
mockExternalBackEnd.enqueue(new MockResponse()
.setResponseCode(HttpStatus.OK.value())
.setBody(objectMapper.writeValueAsString(StubResponse.builder().status(HttpStatus.OK.name()).build()))
.addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE));
}
@DynamicPropertySource
static void updateWebClientConfig(DynamicPropertyRegistry dynamicPropertyRegistry) {
dynamicPropertyRegistry.add("spring.cloud.gateway.routes[0].id", () -> "mock");
dynamicPropertyRegistry.add("spring.cloud.gateway.routes[0].predicates[0]", () -> "Path=/services/mock/**");
dynamicPropertyRegistry.add("spring.cloud.gateway.routes[0].uri",
() -> String.format("http://localhost:%s", mockExternalBackEnd.getPort()));
}
@Test
void testAccessServiceUsingTokenIsOk() {
webTestClient
.get()
.uri("/services/mock/stub")
.exchange()
.expectStatus().isForbidden();
}
为此,您的 SecurityConfiguration 应该有一个路径匹配器,表示“/services/**”路径受身份验证保护,并且您的过滤器已注册:
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfiguration {
...
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
// Note: Omitted some configurations
http
.csrf()
.disable()
.addFilterAt(new MyFilter(), SecurityWebFiltersOrder.HTTP_BASIC)
.authenticationManager(reactiveAuthenticationManager())
.and()
.authorizeExchange()
.pathMatchers("/").permitAll()
.pathMatchers("/services/**").authenticated()
return http.build();
}
...
}
然后您可以断言服务是否已联系并回复了预期的答案:
StubResponse expectedResponse = StubResponse.builder().status(HttpStatus.OK.name()).build();
StubResponse externalServiceResponse = webTestClient
.get()
.uri("/services/mock/stub")
.header("Authorization", "Bearer ...")
.exchange()
.expectStatus().isOk()
.expectBody(StubResponse.class)
.returnResult()
.getResponseBody();
assertThat(externalServiceResponse).isEqualTo(expectedResponse);