Spring SecurityのformLoginに対するテストコードを書く

Spring Securityの認証・認可の単体テストを書いたので、つまづいた点などを残しておく。
`@SpringBootTest`などを付けた結合テストの事例は多かったけどUnitテストレベルの書き方が見つからなかったので。

WebアプリがREST APIだったので主にレスポンスのHTTPステータスを検証することが目的となっており、画面遷移などは無く、ログイン専用のControllerなども用意していない。

::: note warn
SpringSecurity5.7から、WebSecurityConfigurerAdapterを継承した書き方がDeprecatedになりました。
これによってこの記事で書かれているコードは古いのでこちらを参照
[SpringSecurityのセキュリティ設定の書き方を変えたのでformLoginテストも対応した](https://qiita.com/kazokmr/items/1b87db4787c7cdb6c76c)
説明している内容は引き続き有効なのでこの記事はこのまま残します。
:::

## 参考

https://spring.pleiades.io/spring-security/site/docs/current/reference/html5/#securitymockmvcrequestbuilders


## プロダクションコード

フォームログインに関連する部分を抜粋

```SecurityConfig.kt

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
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.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.csrf.CookieCsrfTokenRepository
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource

@EnableWebSecurity
class SecurityConfig(private val authenticationService: AuthenticationService) : WebSecurityConfigurerAdapter() {

    // Accountのパスワードをエンコードする際に必要となるのでBeanにする
    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()

    // パスワード認証時 `configure(AuthenticationManagerBuilder)` にエンコードするのでDI
    @Autowired
    private lateinit var passwordEncoder: PasswordEncoder

    override fun configure(http: HttpSecurity) {
        http.authorizeRequests()
            .mvcMatchers("/login").permitAll()
            .mvcMatchers("/admin/**").hasAuthority(RoleType.ADMIN.toString())
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginProcessingUrl("/login")
            .usernameParameter("email")
            .passwordParameter("pass")
            .successHandler(CustomeAuthenticationSuccessHandler())
            .failureHandler(CustomeAuthenticationFailureHandler())
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(CustomeAuthenticationEntryPoint())
            .accessDeniedHandler(CustomeAccessDeniedHandler())
            .and()
            .csrf()
            .ignoringAntMatchers("/login")
            .csrfTokenRepository(CookieCsrfTokenRepository())
            .and()
            .cors()
            .configurationSource(corsConfigurationSource())
    }

    override fun configure(auth: AuthenticationManagerBuilder) {
        auth.userDetailsService(CustomeUserDetailsService(authenticationService))
            .passwordEncoder(passwordEncoder)
    }
    ......
}
```
上の `@Autowired`を付けてDIしているpasswordEncoder変数は書かずに、`configure(AuthenticationManagerBUilder)`で、`.passwordEncoder(passwordEncoder())`でも結果は同じ。

```CustomeUserDetailsService.kt

import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException

class CustomeUserDetailsService(private val authenticationService: AuthenticationService) : UserDetailsService {

    override fun loadUserByUsername(username: String): UserDetails =
        when (val account = authenticationService.findAccount(username)) {
            null -> throw UsernameNotFoundException("無効なユーザー名です $username")
            else -> CustomeUserDetails(account)
        }
}

data class CustomeUserDetails(
    val id: Long,
    val email: String,
    val pass: String,
    val name: String,
    val roleType: RoleType
) : UserDetails {
    constructor(account: Account) : this(account.id, account.email, account.password, account.name, account.roleType)

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        return AuthorityUtils.createAuthorityList(this.roleType.toString())
    }

    override fun getPassword(): String {
        return this.pass
    }

    override fun isEnabled(): Boolean {
        return true
    }

    override fun getUsername(): String {
        return this.email
    }

    override fun isAccountNonExpired(): Boolean {
        return true
    }

    override fun isAccountNonLocked(): Boolean {
        return true
    }

    override fun isCredentialsNonExpired(): Boolean {
        return true
    }
}
```

## テストコード

FormLoginのテストは、認証成功時と失敗時のハンドリングを確認することを主な目的としていて、以下の部分になる。

```kotlin
.formLogin()
    .loginProcessingUrl("/login")
    .usernameParameter("email")
    .passwordParameter("pass")
    .successHandler(CustomeAuthenticationSuccessHandler()) // HTTP_STATUS: 200(OK) を返す
    .failureHandler(CustomeAuthenticationFailureHandler()) // HTTP_STATUS: 401(Unauthorized) を返す
```

ついでに以下のExceptionHandlingもテストコードに書いているが、特に認可については各Controllerでテストするべきだと思う

```kotlin
.exceptionHandling()
    .authenticationEntryPoint(CustomeAuthenticationEntryPoint()) // HTTP_STATUS: 401(Unauthorized) を返す
    .accessDeniedHandler(CustomeAccessDeniedHandler())           // HTTP_STATUS: 403(Forbidden) を返す
```

```FormLoginTest.kt

import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.whenever
import org.junit.jupiter.api.DisplayName
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.crypto.password.PasswordEncoder
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@WebMvcTest(controllers = [AdminController::class])
internal class FormLoginTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var passwordEncoder: PasswordEncoder

    @MockBean
    private lateinit var authenticationService: AuthenticationService

    // USERロールではアクセス出来ない仕様なのでテスト
    @MockBean
    private lateinit var adminService: AdminService

    @Test
    @DisplayName("ユーザー名とパスワードが一致すればログイン認証に成功する")
    fun `formLogin when account exists then success authentication`() {

        // Given
        val email = "test@example.com"
        val pass = "passpass"
        val role = RoleType.USER

        val account = Account(1, email, passwordEncoder.encode(pass), "test", role)
        whenever(authenticationService.findAccount(any() as String)).thenReturn(account)

        // When
        mockMvc
            .perform(
                formLogin()
                    .loginProcessingUrl("/login")
                    .user("email", email)
                    .password("pass", pass)
            )
            .andExpect(authenticated().withUsername(email))
            .andExpect(status().isOk)
    }

    @Test
    @DisplayName("パスワードが違うとログイン認証に失敗する")
    fun `formLogin when password is incorrectly then failure authentication`() {

        // Given
        val email = "test@example.com"
        val pass = "passpass"
        val role = RoleType.USER
        val account = Account(1, email, passwordEncoder.encode(pass), "test", role)
        whenever(authenticationService.findAccount(any() as String)).thenReturn(account)

        // When
        mockMvc
            .perform(
                formLogin()
                    .loginProcessingUrl("/login")
                    .user("email", email)
                    .password("pass", "invalid")
            )
            .andExpect(unauthenticated())
            .andExpect(status().isUnauthorized)
    }

    @Test
    @DisplayName("登録されていないユーザー名ならログイン認証に失敗する")
    fun `formLogin when account does not exist then failure authentication`() {

        // Given
        whenever(authenticationService.findAccount(any() as String)).thenReturn(null)

        // When
        mockMvc
            .perform(
                formLogin()
                    .loginProcessingUrl("/login")
                    .user("email", "unregister")
                    .password("pass", "invalid")
            )
            .andExpect(unauthenticated())
            .andExpect(status().isUnauthorized)
    }

    @Test
    @DisplayName("認証されていなければアクセスできない")
    fun `exceptionHandling when account does not authenticate then can not access except login`() {

        // When
        mockMvc
            .perform(
                delete("/admin/delete/100")
                    .with(csrf().asHeader())
            )
            .andExpect(unauthenticated())
            .andExpect(status().isUnauthorized)
    }

    @Test
    @DisplayName("認証アカウントが権限を持たなければアクセスできない")
    @WithCustomMockUser(roleType = RoleType.USER)
    fun `exceptionHandling when account has not authorization then can not access`() {

        // When
        mockMvc
            .perform(
                delete("/admin/delete/200")
                    .with(csrf().asHeader())
            )
            .andExpect(status().isForbidden)
    }
}
```

## つまずきポイント

### `@WebMvcTest`でControllerクラスを指定する

指定しないと全てのControllerが起動するため、それぞれでInjectionしているServiceなどのコンポーネントの`@Autowired`が必要になる。
このアプリは勉強用のサンプルなのでServiceクラスの数も少なかったが実践を考えるとよろしく無いのでControllerを指定した方が良い。

今回は exceptionHandlingのテストも兼ねていたので、ADMIN権限だけがアクセスできるControllerを指定することにした。

### AuthenticationServiceクラスをモック化する

UserDetailsを取得するためにDBにアクセスすることになる。 TestContainersなどを利用してテストDBを用いることもできるが、より簡易的なテストのためにMockにした。

### PasswordEncordを忘れない

前述の通りDBからAccountオブジェクトの取得をMock化したが、パスワードをエンコードしていなかったので、認証エラーと判定されてしまっていた。　
DBに接続していればエンコードされた状態で保管されていたんだけど、すっかり忘れてしまって悩んだ。

ちなみに平文状態でテストを実行すると「パスワードがエンコードされていないよ」とWarnログが出るのでそこで気づける。

### userとpasswordのパラメータ名を確認する

SpringSecurityのデフォルトは、`username` と `password` だが、パラメータ名を変えている場合は正しいパラメータ名に対してテストデータをセットする必要がある。
その場合は、以下のように`user()`および`password()`メソッドの引数に　`("パラメータ名","入力値")`として渡す。

```kotlin
mockMvc
    .perform(
        formLogin()
            .user("email", email)
            .password("pass", "invalid")
    )
```

### 認証失敗時のエラーハンドリング

登録されていないUserを検索した場合、 `authenticationService.findAccount(username)` の戻り値がNullとなるが、呼び出し元の`CustomeUserDetailsService.loadUserByUsername()`で、UsernameNotFoundExceptionをThrowしていなかったので、テストは成功するがエラーログが出力されていた。
なので以下のようにエラーハンドリングを行うようにコードを修正した。
＊こういった発見ができるのもテストコードを書くことの目的だと再認識できたってお話。

```kotlin
class CustomeUserDetailsService(private val authenticationService: AuthenticationService) : UserDetailsService {

    override fun loadUserByUsername(username: String): UserDetails =
        when (val account = authenticationService.findAccount(username)) {
            null -> throw UsernameNotFoundException("無効なユーザー名です $username")
            else -> CustomeUserDetails(account)
        }
}
```

## まとめ

テストコードを書くと、SpringSecurityの仕組みも段々と理解できるようになる。