精通Spring Boot 3 : 9. Spring Boot 安全 (3)

连接用户应用程序的用户界面

现在是时候将用户应用程序与用户界面连接起来了。在源代码中,您会找到一个名为 users-ui 的文件夹,里面包含了所有的 HTML、JavaScript(jQuery)以及构建用户应用程序所需的资源。在继续进行用户界面和后端代码之前,请花一些时间审查这些内容并分析其中的工作原理。

让我们先来看看 login.js 文件,并审查其中一部分代码,如清单 9-9 所示。

...
$('#btn__login').click(function(e) {
    e.preventDefault();
    let user_email = $('input[name="user_email"]').val();
    let user_password = $('input[name="user_password"]').val();
    $.ajax({
      url: 'http://localhost:8080/users/'+user_email,
      method: 'GET',
      headers: {
        'Authorization': 'Basic ' + btoa(user_email + ':' + user_password),
        'Content-Type': 'application/json'
      }
    })
    .done(function( response, textStatus, xhr ) {
    // ....
}

用户界面/assets/js/login.js 中的示例代码片段 9-9

请注意,在 $.ajax 调用中,我们将 url 值设置为 /users/{email} 端点。你可能会觉得这很不寻常,因为它可以是 /authenticate 或类似的接口。没错,我们可以这样做,但为了说明这个用户界面是如何工作的,我们将使用这个端点作为身份验证的一部分。

代码中另一个重要的部分是 headers 值,我们使用 HTTP Authorization 头和 btoa() 方法,该方法可以将我们要发送的字符串编码为 base64,这里指的是用户名和密码。当我们从后端接收到响应时,会在 done 函数中获取到。

现在,切换到用户应用(后端),并修改或创建 UserSecurityConfig 类。请参见列表 9-10。

package com.apress.users.security;
import com.apress.users.repository.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
public class UserSecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http, AuthenticationProvider authenticationProvider,
                                    CorsConfigurationSource corsConfigurationSource) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .cors(cors -> cors.configurationSource(corsConfigurationSource))
                .authorizeHttpRequests( auth -> auth.anyRequest().authenticated())
                .authenticationProvider(authenticationProvider)
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
    @Bean
    public AuthenticationProvider authenticationProvider(UserRepository userRepository){
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(new UserSecurityDetailsService(userRepository));
        return provider;
    }
}

9-10 src/main/java/com/apress/users/security/UserSecurityConfig.java

列表 9-10 展示了一个与用户界面兼容的修改版 UserSecurityConfig 类。我们来一起回顾一下吧:

  • 正如本章前面提到的,Spring Security 支持跨源资源共享,这会阻止任何脚本连接或使用 AJAX 调用不同的源。在这种情况下,请求不会在同一源上进行,因为它们将通过 UI(位于不同的服务器上)发起,因此我们需要提供访问权限(当然需要知道客户端),并且需要通过 CorsConfigurationSource 声明一些规则。
  • 我们还在 SecurityFilterChain 中添加了一种新的身份验证方式,即使用默认设置的 formLogin。因此,如果我们尝试直接访问 /users 端点,Spring Security 将会返回一个要求输入用户名和密码的登录页面。
  • 我们正在创建 CorsConfigurationSource bean,以声明请求的来源、头部和方法,以识别请求的来源。在这种情况下,我们为了演示目的启用了所有选项,但当我们的应用程序中嵌入客户端时,我们必须限制并配置安全性,以防止黑客攻击。

用户前端与后端:让我们来试试吧!

现在,让我们在用户界面中运行我们的两个应用程序。首先,请确保用户应用程序(后端)已启动并正常运行。然后,在你的用户界面中,你可以使用 Python 或其他任何能够提供这些 HTML 文件的应用程序。如果你使用的是一个用于用户界面的 IDE,它应该有方法来运行你的应用程序。例如,如果你使用 JetBrains WebStorm IDE,只需右键单击 index.html 文件并选择“运行”。如果你使用 VS Code,可以使用 Live Server 插件,并在状态栏中点击“Go Live”按钮,这将会在 5500 端口启动一个浏览器窗口。

使用 Python 进入 users-ui 文件夹的根目录,然后执行以下命令:

python3 -m http.server

这将打开 8000 端口。如果你在浏览器中输入 http://localhost:8000,你将看到图 9-2 所示的界面。


图 9-2 展示了用户/复古应用程序,目前我们正在使用复古应用程序视图来查看用户,因此这并不是错误。接下来,您可以使用以下凭据登录:

manager@email.com/aw2s0meR!

登录成功后,您应该能看到如图 9-3 所示的用户界面。


如果你想测试它的功能,可以随意尝试。列出的用户是我们在后端的 UserConfig 类中设置的默认用户。

现在,如果你直接访问后端 http://localhost:8080,你会看到图 9-4 中展示的登录页面。


图 9-4 展示了我们在 SecurityFilterChain 中设置的 formLogin 声明的默认实现。如果您提供凭据,将会看到以 JSON 格式呈现的用户列表,如图 9-5 所示。

身份验证后显示的用户列表(JSON 格式)

现在你已经知道如何使用 Spring Security 和 Spring Boot 将外部用户界面集成到你的用户应用程序中!在进入下一部分之前,请记住我们在这一部分中只讨论了身份验证。在接下来的部分中,我们还将讨论授权内容。

为我的复古应用程序增加安全性

我的复古应用程序集成了反应式技术,Spring Security 与 Spring Boot 在基于 WebFlux 的依赖关系时提供类似的自动配置。这里的主要思想是连接两个应用程序:用户应用程序将提供用户信息查询,而我的复古应用程序将根据本节中定义的安全配置来保护这些信息(用户信息)。在我的复古应用程序中,我们不仅会定义身份验证,还会定义授权,如图 9-6 所示。


接下来,我们将审查源代码,您可以在 09-security/myretro 文件夹中找到它。如果您打开 build.gradle 文件,您将看到清单 9-11 中显示的代码。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.3'
    id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
    mavenCentral()
}
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
    // Web
    implementation 'org.webjars:bootstrap:5.2.3'
    implementation 'org.webjars:webjars-locator-core'
    // DevTools
    implementation 'org.springframework.boot:spring-boot-devtools'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:mongodb'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testImplementation 'io.projectreactor:reactor-test'
}
tasks.named('test') {
    useJUnitPlatform()
}
test {
    testLogging {
        events "passed", "skipped", "failed" //, "standardOut", "standardError"
        showExceptions true
        exceptionFormat "full"
        showCauses true
        showStackTraces true
        // Change to `true` for more verbose test output
        showStandardStreams = false
    }
}

9-11 build.gradle

列表 9-11 展示了添加到 build.gradle 文件中的 spring-boot-starter-security 依赖。

接下来,打开或创建 UserClient 类。请参阅清单 9-12。

package com.apress.myretro.client;
import com.apress.myretro.config.MyRetroProperties;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@Component
public class UserClient {
    WebClient webClient;
    public UserClient(WebClient.Builder webClientBuilder, MyRetroProperties props) {
        this.webClient = webClientBuilder
                .baseUrl(props.getUsers().getServer())
                .defaultHeaders(headers -> headers.setBasicAuth(props.getUsers().getUsername(), props.getUsers().getPassword()))
                .build();
    }
    public Mono<User> getUserInfo(String email){
        return webClient.get()
                .uri("/users/{email}",email)
                .retrieve()
                .bodyToMono(User.class);
    }
    public Mono<String> getUserGravatar(String email){
        return webClient.get()
                .uri("/users/{email}/gravatar",email)
                .retrieve()
                .bodyToMono(String.class);
    }
}

9-12 src/main/java/apress/com/myretro/client/UserClient.java

UserClient 类被标记为 Spring bean,使用 @Component 注解,这使得它在需要时可以被使用。这个类类似于 RestTemplate 类,但专为反应式应用程序设计。它有自己处理 WebFlux 组件(如 Flux 和 Mono 类型)的方法,并且也可以在非反应式应用程序中使用。UserClient 类有两个方法使用 WebClient 类:

  • getUserInfo 方法将通过发送电子邮件(作为方法参数)使用 /users/{email} 端点来获取用户信息。在构造函数中,我们使用 Users App(在这种情况下是 Users Service)访问其 API 所需的基本身份验证。我们使用 MyRetroProperties 类,该类由 Spring 创建,并期望提供服务器、端口、用户名和密码属性。(您可以查看 MyRetroProperties 类以了解这些属性,以及 application.properties 文件。换句话说,为了确保我的复古应用程序的安全,我们需要一些用户,而用户应用程序保存了这些信息。因此,我们将用户应用程序作为我的复古应用程序的身份验证和授权机制,并使用 WebClient 类来访问用户服务进行身份验证。
  • getUserGravatar 方法将根据电子邮件获取图像,并通过 WebClient 与用户服务进行通信。

请注意,这两种方法都在调用一个非反应式应用,并将响应转换为 Mono 类型。

接下来,打开或创建 RetroBoardSecurityConfig 类。请参阅第 9-13 号列表。

package com.apress.myretro.security;
import com.apress.myretro.client.User;
import com.apress.myretro.client.UserClient;
import com.apress.myretro.client.UserRole;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ReactiveHttpOutputMessage;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Configuration
public class RetroBoardSecurityConfig {
    @Bean
    SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                           ReactiveAuthenticationManager reactiveAuthenticationManager,
                           CorsConfigurationSource corsConfigurationSource) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .cors(cors -> cors.configurationSource(corsConfigurationSource))
                .authorizeExchange( auth -> auth
                        .pathMatchers(HttpMethod.POST,"/retros/**").hasRole("ADMIN")
                        .pathMatchers(HttpMethod.DELETE,"/retros/**").hasRole("ADMIN")
                        .pathMatchers("/retros/**").hasAnyRole("USER","ADMIN")
                        .pathMatchers("/","/webjars/**").permitAll()
                )
                .authenticationManager(reactiveAuthenticationManager)
                .formLogin(formLoginSpec -> formLoginSpec.authenticationSuccessHandler(serverAuthenticationSuccessHandler()))
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
    @Bean
    ServerAuthenticationSuccessHandler serverAuthenticationSuccessHandler(){
        return (webFilterExchange, authentication) -> {
            return webFilterExchange.getExchange().getSession()
                    .flatMap(session -> {
User user = (User) authentication.getDetails();
                    var body = """
                {
                    "email": "%s",
                    "name": "%s",
                    "password": "%s",
                    "userRole": "%s",
                    "gravatarUrl": "%s",
                    "active": %s
                }
                """.formatted(
                            user.email(),
                            user.name(),
                            user.password(),
                            authentication.getAuthorities().stream()
                                    .map(GrantedAuthority::getAuthority)
                                    .map(role -> role.replace("ROLE_",""))
                                    .collect(Collectors.joining(",")),
                            user.gravatarUrl(),
                            true);                        webFilterExchange.getExchange().getResponse().setStatusCode(HttpStatusCode.valueOf(200));
                        ReactiveHttpOutputMessage response = webFilterExchange.getExchange().getResponse();
                        response.getHeaders().add("Content-Type","application/json");
                        response.getHeaders().add("X-MYRETRO","SESSION="+session.getId()+"; Path=/; HttpOnly; SameSite=Lax");
                        DataBuffer dataBufferPublisher = response.bufferFactory().wrap(body.getBytes());
                        return response.writeAndFlushWith( Flux.just(dataBufferPublisher).windowUntilChanged());
                    });
        };
    }
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        //configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE","OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("x-ijt","Set-Cookie","Cookie","Content-Type","X-MYRETRO","Allow","Authorization","Access-Control-Allow-Origin","Access-Control-Allow-Credentials","Access-Control-Allow-Headers","Access-Control-Allow-Methods","Access-Control-Expose-Headers","Access-Control-Max-Age","Access-Control-Request-Headers","Access-Control-Request-Method","Origin","X-Requested-With","Accept","Accept-Encoding","Accept-Language","Host","Referer","Connection","User-Agent"));
        configuration.setExposedHeaders(Arrays.asList("x-ijt","Set-Cookie","Cookie","Content-Type","X-MYRETRO","Allow","Authorization","Access-Control-Allow-Origin","Access-Control-Allow-Credentials","Access-Control-Allow-Headers","Access-Control-Allow-Methods","Access-Control-Expose-Headers","Access-Control-Max-Age","Access-Control-Request-Headers","Access-Control-Request-Method","Origin","X-Requested-With","Accept","Accept-Encoding","Accept-Language","Host","Referer","Connection","User-Agent"));
        configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:*"));
        configuration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(UserClient userClient){
        return authentication -> {
            String username = authentication.getName();
            String password = authentication.getCredentials().toString();
            Mono<User> userResult = userClient.getUserInfo(username);
            return userResult.flatMap( user -> {
                if(user.password().equals(password)){
                    List<GrantedAuthority> grantedAuthorities = user.userRole().stream()
                            .map(UserRole::name)
                            .map("ROLE_"::concat)
                            .map(SimpleGrantedAuthority::new)
                            .collect(Collectors.toList());
                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username,password,grantedAuthorities);
                    authenticationToken.setDetails(user);
                    return Mono.just(authenticationToken);
                }else{
                    return Mono.error(new BadCredentialsException("Invalid username or password"));
                }});
        };
    }
}

9-13 src/main/java/apress/com/myretro/security/RetroBoardSecurityConfig.java

RetroBoardSecurityConfig 类包括以下内容:

  • SecurityWebFilterChain:这个接口很熟悉,对吧?在用户应用中,我们使用 SecurityFilterChain 接口来处理 Servlet 网络应用,而在这种情况下,我们使用 WebFlux(反应式网络应用),因此需要使用其对应的 SecurityWebFilterChain 接口,它处理 Mono 和 Flux 类型。它的行为与 SecurityFilterChain 相同,可以帮助我们声明所需的任何自定义过滤器。在我们的示例中,我们还需要将 ServerHttpSecurity、ReactiveAuthenticationManager 和 CorsConfigurationSource bean 作为方法的参数。
  • ServerHttpSecurity 类与其对应的 HttpSecurity 类非常相似,负责配置特定请求的所有安全性。默认情况下(如果未使用),安全性将适用于所有请求(这时会获取用户定义的用户名和在日志中打印的密码)。在这种情况下,我们定义了一些规则:
    • 我们正在关闭 CSRF 过滤器。
    • 我们正在设置 CORS 过滤器。
    • 我们正在为 /retros/* 端点请求定义安全访问。我们添加了 POST 和 DELETE 方法,角色为 ADMIN,而对于其他 HTTP 请求(如 GET),则允许 USER 或 ADMIN 角色。我们还允许任何 / 和 /webjars/** 端点,这些端点对应于 index.html 页面及其资源,如图像、CSS 和 JS 脚本。此外,我们使用了 ReactiveAuthenticationManager,并定义了表单和基本身份验证。
  • ReactiveAuthenticationManager:这个 bean 用于与用户应用进行联系。一个重要的细节是我们使用了 Mono 和 Flux 类型,因此需要使用一些反应式方法并进行适当的处理,以获取所需的值。ReactiveAuthenticationManager 是一个函数式接口,定义了 Mono authenticate(Authentication authentication) 方法;当我们收到请求时,可以获取发送的信息,即用户名和密码,然后可以使用我们的 UserClient 获取用户信息,并通过创建 UsernamePasswordAuthenticationToken 或抛出 BadCredentialsException 异常来返回 Mono。这就是身份验证过程的一部分。 此外,重要的是要知道,UsernamePasswordAuthenticationToken 类扩展了 AbstractAuthenticationToken 抽象类,该类包含 Object 详细信息,我们将在其中添加用户信息(由 UserClient 类获取),并将其作为 authenticationSuccessHandler 配置的一部分使用。
  • CorsConfigurationSource:该接口定义了 CorsConfiguration getCorsConfiguration(ServerWebExchange exchange)方法,我们可以通过添加请求头和来源,并为特定端点允许凭证来进行配置。在这种情况下,我们使用/**语法来允许所有内容。
  • 服务器认证成功处理程序:我们将使用一个外部登录页面(见图 9-6),这需要我们添加一些配置以使其正常工作,因此我们需要定义 authenticationSuccessHandler。我们正在声明一个 ServerAuthenticationSuccessHandler bean。该接口声明了一个方法 Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication),当应用程序成功认证时会被调用。所有这些都是必要的,因为我们的用户界面将指向/login 端点,而此配置将生成后续调用所需的 SESSION ID。 换句话说,这个调用将返回 UI 所需的响应,即期望包含所有用户信息的 JSON 对象,以便前端能够应用必要的逻辑来区分 ADMIN 和 USER 角色。

正如你所看到的,我们需要为反应式 Web 应用程序添加更多配置,但使用 Spring Security 的主要目的仍然相同。Spring Boot 通过消除在仅使用 Spring 应用程序时需要添加的样板代码,极大地简化了这一过程。在你继续下一部分之前,请仔细审查代码,并以自己的节奏进行分析。请记住,我们通过集成两个应用程序——用户应用程序用于检索用户信息,以及我的复古应用程序用于根据我们在 ServerHttpSecurity 配置中设定的规则来实施安全性——同时实现了身份验证和授权。

接下来,我们来测试一下我的复古应用。

对我的复古应用程序的授权进行单元测试安全性

为了测试我的复古应用程序,我们将进行单元测试,而不是集成测试。不过,我们会使用第 8 章中用于集成测试的@MockBean 来测试我们为某些端点设置的授权,因为我们将模拟身份验证。

创建或打开 RetroBoardWebFluxTests 类。请参阅第 9-14 。

package com.apress.myretro;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.CardType;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.client.UserClient;
import com.apress.myretro.security.RetroBoardSecurityConfig;
import com.apress.myretro.service.RetroBoardService;
import com.apress.myretro.web.RetroBoardController;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.BodyInserters;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.UUID;
@Import({RetroBoardSecurityConfig.class})
@WithMockUser
@WebFluxTest(controllers = {RetroBoardController.class})
public class RetroBoardWebFluxTests {
    @MockBean
    RetroBoardService retroBoardService;
    @MockBean
    UserClient userClient;
    @Autowired
    private WebTestClient webClient;
    @Test
    void getAllRetroBoardTest(){
        Mockito.when(retroBoardService.findAll()).thenReturn(Flux.just(
                new RetroBoard(UUID.randomUUID(),"Simple Retro", Arrays.asList(
                        new Card(UUID.randomUUID(),"Happy to be here", CardType.HAPPY),
                        new Card(UUID.randomUUID(),"Meetings everywhere", CardType.SAD),
                        new Card(UUID.randomUUID(),"Vacations?", CardType.MEH),
                        new Card(UUID.randomUUID(),"Awesome Discounts", CardType.HAPPY),
                        new Card(UUID.randomUUID(),"Missed my train", CardType.SAD)
                ))
        ));
        webClient.get()
                .uri("/retros")
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus().isOk()
                .expectBody().jsonPath("$[0].name").isEqualTo("Simple Retro");
        Mockito.verify(retroBoardService,Mockito.times(1)).findAll();
    }
    @Test
    void findRetroBoardByIdTest() {
        UUID uuid = UUID.randomUUID();
        Mockito.when(retroBoardService.findById(uuid)).thenReturn(Mono.just(
                new RetroBoard(uuid, "Simple Retro", Arrays.asList(
                        new Card(UUID.randomUUID(), "Happy to be here", CardType.HAPPY),
                        new Card(UUID.randomUUID(), "Meetings everywhere", CardType.SAD),
                        new Card(UUID.randomUUID(), "Vacations?", CardType.MEH),
                        new Card(UUID.randomUUID(), "Awesome Discounts", CardType.HAPPY),
                        new Card(UUID.randomUUID(), "Missed my train", CardType.SAD)
                ))
        ));
        webClient.get()
                .uri("/retros/{uuid}", uuid.toString())
                .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .exchange()
                .expectStatus().isOk()
                .expectBody(RetroBoard.class);
        Mockito.verify(retroBoardService, Mockito.times(1)).findById(uuid);
    }
    @Test
    @WithMockUser(roles = "ADMIN")
    void saveRetroBoardTest(){
        RetroBoard retroBoard = new RetroBoard();
        retroBoard.setName("Simple Retro");
        Mockito.when(retroBoardService.save(retroBoard))
                .thenReturn(Mono.just(retroBoard));
        webClient.post()
                .uri("/retros")
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(retroBoard))
                .exchange()
                .expectStatus().isOk();
        Mockito.verify(retroBoardService,Mockito.times(1)).save(retroBoard);
    }
    @Test
    @WithMockUser(roles = "ADMIN")
    void deleteRetroBoardTest(){
        UUID uuid = UUID.randomUUID();
        Mockito.when(retroBoardService.delete(uuid)).thenReturn(Mono.empty());
        webClient.delete()
                .uri("/retros/{uuid}",uuid.toString())
                .exchange()
                .expectStatus().isOk();
        Mockito.verify(retroBoardService,Mockito.times(1)).delete(uuid);
    }
}

9-14 src/test/java/apress/com/myretro/RetroBoardWebFluxTests.java

RetroBoardWebFluxTests 类中包含以下注解:

  • 我们在两个主要类 RetroBoardService 和 UserClient 中使用这个注解,因为我们希望应用授权并检查角色是否正常工作。
  • 我们在类级别使用这个注解,但在 saveRetroBoardTest 和 deleteRetroBoardTest 方法中,通过添加 roles 参数并定义 ADMIN 角色来覆盖它。如果您从这些方法中删除这个注解,测试将会失败。

如果你运行测试,它们应该能够顺利通过,没有任何问题。正如在清单 9-14 的末尾所示,我们只是通过模拟一些服务进行单元测试,但测试授权是我们在这个项目中添加的一个重要关键要素。