[Spring Boot 2.5.X]WebMvcTestでOAuth2.0の認証をパスしようとしたらハマった話

# はじめに
OAuth2.0の認証を行うリソースサーバーのコントローラーをテストする際に、Spring Boot 2.5.X で発生するエラーを踏んでしまい、解消に時間がかかったのでメモ。
REST APIのコントローラー部分のみのテストをしたかったので`@WebMvcTest`を使用。
spring-security-testの`@WithMockUser`を使用してテストしようとしたところ、テスト実行時にBeanCreationExceptionが発生。


# エラー詳細
以下のログが出力されました。
```sh
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.5.1)

2022-04-27 21:41:51.414  INFO 21628 --- [           main] c.e.s.c.SampleRestControllerTest         : Starting SampleRestControllerTest using Java 11.0.13 on chaffstun-PC with PID 21628 (started by chaffstun in C:\Users\chaffstun\Desktop\springsecurityoauth2demo\springsecurityoauth2demo)
2022-04-27 21:41:51.415  INFO 21628 --- [           main] c.e.s.c.SampleRestControllerTest         : No active profile set, falling back to default profiles: default
2022-04-27 21:41:52.029  WARN 21628 --- [           main] o.s.w.c.s.GenericWebApplicationContext   : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'springSecurityFilterChain' defined in class path resource [org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [javax.servlet.Filter]: Factory method 'springSecurityFilterChain' threw exception; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtDecoderByIssuerUri' defined in class path resource [org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration$JwtDecoderConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoderByIssuerUri' threw exception; nested exception is java.lang.IllegalArgumentException: Unable to resolve the Configuration with the provided Issuer of "https://idp.example.com/issuer"
2022-04-27 21:41:52.031  INFO 21628 --- [           main] ConditionEvaluationReportLoggingListener : 

Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2022-04-27 21:41:52.037 ERROR 21628 --- [           main] o.s.boot.SpringApplication               : Application run failed

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'springSecurityFilterChain' defined in class path resource [org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [javax.servlet.Filter]: Factory method 'springSecurityFilterChain' threw exception; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtDecoderByIssuerUri' defined in class path resource [org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration$JwtDecoderConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoderByIssuerUri' threw exception; nested exception is java.lang.IllegalArgumentException: Unable to resolve the Configuration with the provided Issuer of "https://idp.example.com/issuer"
	at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:658) ~[spring-beans-5.3.8.jar:5.3.8]
	at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:486) ~[spring-beans-5.3.8.jar:5.3.8]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1334) ~[spring-beans-5.3.8.jar:5.3.8]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1177) ~[spring-beans-5.3.8.jar:5.3.8]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:564) ~[spring-beans-5.3.8.jar:5.3.8]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:524) ~[spring-beans-5.3.8.jar:5.3.8]
	at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:335) ~[spring-beans-5.3.8.jar:5.3.8]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234) ~[spr

以下略
```

ログよりJwtDecoderのインスタンスを生成するため、jwtDecoderByIssuerUriへアクセスしようとしているみたいですね。

# 実装例
検証にあたって作成した実装例。

## 依存関係
Spring Bootは2.5.9を使用。
```xml:pom.xml
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<!-- test -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		
		<dependency>
 			 <groupId>org.springframework.security</groupId>
 			 <artifactId>spring-security-test</artifactId>
 			 <version>5.6.3</version>
		</dependency>
		
		<dependency>
  			<groupId>org.mockito</groupId>
  			<artifactId>mockito-junit-jupiter</artifactId>
 			 <version>4.5.1</version>
		</dependency>
	</dependencies>

```

## コントローラー

GETメソッドでhelloを返却するだけのものを用意。

```java:SampleRestController.java
package com.example.springsecurityoauth2demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SampleRestController {

	@GetMapping("/")
	public String hello() {
		return "hello";
	}
}
```

## リソースサーバーの設定クラス

accessロールを持つ場合にアクセスを許可する。
クライアントクレデンシャルの場合を想定。

```java:SecurityConfig.java
package com.example.springsecurityoauth2demo.controller;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeHttpRequests(authorize -> authorize
			.mvcMatchers("/")
			.hasRole("access")
			.anyRequest().authenticated())
			.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
	}
}
```

## application.yml

issuer-uriにアクセストークンの検証先を設定する。

```yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/issuer
```

## テストクラス

本来であれば以下のテストで問題ないはずだったが、BeanCreationExceptionが発生してしまう。
ちなみに**Spring Boot 2.6.0以降は修正されていて**以下のテスト問題なく実行され、結果もグリーンになる。

```java:SampleRestControllerTest.java
package com.example.springsecurityoauth2demo.controller;

import static org.hamcrest.CoreMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(SampleRestController.class)
class SampleRestControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@Test
	@WithMockUser(roles = "access")
	void accessロールを持つリクエストは許可される() throws Exception {
		this.mockMvc.perform(get("/"))
			.andExpect(status().isOk())
			.andExpect(content().string(containsString("hello")));
	}

}
```

# 対処方法
はじめはテストの記載方法が間違っているのかもしれないと思い遠回りをしてしまったが、参考資料をもとにたどり着いた解消方法は以下の通り。

## 修正後のテストクラス
```java:SampleRestControllerTest.java
package com.example.springsecurityoauth2demo.controller;

import static org.hamcrest.CoreMatchers.*;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@WebMvcTest(SampleRestController.class)
class SampleRestControllerTest {

	private MockMvc mockMvc;

	@Autowired
	private WebApplicationContext context;

	@MockBean
	private JwtDecoder jwtDecoder;

	@BeforeEach
	void 前準備() {
		this.mockMvc = MockMvcBuilders
			.webAppContextSetup(context)  // JwtDecoderのモックを適用したWebApplicationContextを設定
			.apply(springSecurity())
			.build();
	}

	@Test
	@WithMockUser(roles = "access")
	void accessロールを持つリクエストは許可される() throws Exception {
		this.mockMvc.perform(get("/"))
			.andExpect(status().isOk())
			.andExpect(content().string(containsString("hello")));
	}

}

```

この実装でも問題ありませんが、Junit4の書き方が混ざっているようだったのでJunit5っぽく？書けないか試行錯誤してみると、以下の記載でも問題なく動作しました。

## Junit5っぽく修正したテストクラス
```java
package com.example.springsecurityoauth2demo.controller;

import static org.hamcrest.CoreMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(SampleRestController.class)
class SampleRestControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@MockBean
	private JwtDecoder jwtDecoder;

	@Test
	@WithMockUser(roles = "access")
	void accessロールを持つリクエストは許可される() throws Exception {
		mockMvc.perform(get("/"))
			.andExpect(status().isOk())
			.andExpect(content().string(containsString("hello")));
	}

}
```
単純にJwtDecoderをMockBeanにしてやれば問題ありませんでした。
記事を書く際にエラーログをより詳細に確認しましたが、ログの内容からも上記の発想になりますね。

# ソース
サンプルは[こちら](https://github.com/MrMs1/WebMvcMockTest_with_OAuth2.0)に置いてます。

# おわりに
検証のためSpringInitalizrでサンプルを作成したところ、Spring Bootのバージョンが2.6.7になっていて普通にテストが通ったので少し焦りました笑。
さらに調べてみるとSpring Boot2.5.Xでしか発生していなかったので運が悪かったなと。

# 参考資料
- [11. Testing Method Security](https://docs.spring.io/spring-security/site/docs/5.0.x/reference/html/test-method.html)
- [Spring Boot Resource Server (OAuth2) @MockMvc](https://www.java-forum.org/thema/spring-boot-resource-server-oauth2-mockmvc.193602/)
- [MockMvc と @MockBean で Web レイヤーテスト](https://spring.pleiades.io/guides/gs/testing-web/)
- [OAuth 2.0 リソースサーバー JWT](https://spring.pleiades.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
- [Spring Boot 2.6 Release Notes](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.6-Release-Notes)
- [Adding filters relative to custom ones is broken #9787](https://github.com/spring-projects/spring-security/issues/9787)