feat(ai-character): 관리자 API Phase 1 기반을 추가한다

This commit is contained in:
2026-07-26 05:01:34 +09:00
parent d3564f8c0d
commit 0d2742756f
19 changed files with 4108 additions and 16 deletions

View File

@@ -142,6 +142,15 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
)
fun findByIdInWithTagMappings(@Param("ids") ids: List<Long>): List<ChatCharacter>
@Query(
"""
SELECT c FROM ChatCharacter c
LEFT JOIN FETCH c.creatorMember
WHERE c.id = :id
"""
)
fun findByIdWithCreatorMember(@Param("id") id: Long): ChatCharacter?
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
}

View File

@@ -1,12 +1,22 @@
package kr.co.vividnext.sodalive.common
import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.JwtException
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.servlet.HandlerExceptionResolver
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRequestFilter() {
class ExceptionHandlerFilter(
private val objectMapper: ObjectMapper,
private val apiRequestMatcher: RequestMatcher,
private val apiAuthenticationEntryPoint: AuthenticationEntryPoint,
private val apiExceptionResolver: HandlerExceptionResolver
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
@@ -15,6 +25,18 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
try {
filterChain.doFilter(request, response)
} catch (e: Exception) {
if (apiRequestMatcher.matches(request)) {
if (isAuthenticationFailure(e)) {
val authenticationException = e as? BadCredentialsException
?: BadCredentialsException("Authentication failed", e)
apiAuthenticationEntryPoint.commence(request, response, authenticationException)
} else {
val resolved = apiExceptionResolver.resolveException(request, response, null, e)
if (resolved == null) throw e
}
return
}
response.status = 401
response.contentType = "application/json"
response.characterEncoding = "UTF-8"
@@ -23,4 +45,10 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
response.writer.write(json)
}
}
private fun isAuthenticationFailure(exception: Exception): Boolean {
return exception is JwtException ||
exception is BadCredentialsException ||
(exception is SodaException && exception.messageKey == "common.error.bad_credentials")
}
}

View File

@@ -2,13 +2,19 @@ package kr.co.vividnext.sodalive.configs
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
import kr.co.vividnext.sodalive.jwt.JwtFilter
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity
@@ -17,8 +23,16 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.HttpRequestHandler
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
@Configuration
@EnableWebSecurity
@@ -35,25 +49,94 @@ class SecurityConfig(
}
@Bean
fun webSecurityCustomizer(): WebSecurityCustomizer {
fun webSecurityCustomizer(
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler
): WebSecurityCustomizer {
return WebSecurityCustomizer { web: WebSecurity ->
web
.requestRejectedHandler(aiCharacterAdminSecurityErrorHandler)
.ignoring()
.antMatchers("/h2-console/**", "/favicon.ico", "/error")
}
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
fun aiCharacterAdminErrorResponseWriter(messageSource: SodaMessageSource): AiCharacterAdminErrorResponseWriter {
return AiCharacterAdminErrorResponseWriter(objectMapper, messageSource)
}
@Bean
fun aiCharacterAdminSecurityErrorHandler(
responseWriter: AiCharacterAdminErrorResponseWriter
): AiCharacterAdminSecurityErrorHandler {
return AiCharacterAdminSecurityErrorHandler(
responseWriter,
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH),
WebConfig.createAiCharacterAdminCorsConfiguration()
)
}
@Bean
fun aiCharacterAdminExceptionHandler(
responseWriter: AiCharacterAdminErrorResponseWriter
): AiCharacterAdminExceptionHandler {
return AiCharacterAdminExceptionHandler(
responseWriter,
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
)
}
@Bean
fun aiCharacterAdminFallbackHandlerMapping(
responseWriter: AiCharacterAdminErrorResponseWriter
): SimpleUrlHandlerMapping {
val notFoundHandler = HttpRequestHandler { request, response ->
responseWriter.write(
request,
response,
HttpStatus.NOT_FOUND,
"common.error.invalid_request"
)
}
return SimpleUrlHandlerMapping(
mapOf(AI_CHARACTER_ADMIN_PATH to notFoundHandler),
Ordered.LOWEST_PRECEDENCE - 2
).apply {
setCorsConfigurations(
mapOf(AI_CHARACTER_ADMIN_PATH to WebConfig.createAiCharacterAdminCorsConfiguration())
)
}
}
@Bean
fun filterChain(
http: HttpSecurity,
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler,
aiCharacterAdminExceptionHandler: AiCharacterAdminExceptionHandler
): SecurityFilterChain {
val jwtFilter = JwtFilter(tokenProvider)
val aiCharacterAdminRequestMatcher = AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
val authenticationEntryPoints = linkedMapOf<RequestMatcher, AuthenticationEntryPoint>(
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
)
val delegatingAuthenticationEntryPoint = DelegatingAuthenticationEntryPoint(authenticationEntryPoints).apply {
setDefaultEntryPoint(authenticationEntryPoint)
}
val accessDeniedHandlers = linkedMapOf<RequestMatcher, AccessDeniedHandler>(
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
)
val delegatingAccessDeniedHandler = RequestMatcherDelegatingAccessDeniedHandler(
accessDeniedHandlers,
accessDeniedHandler
)
return http
.cors()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(delegatingAuthenticationEntryPoint)
.accessDeniedHandler(delegatingAccessDeniedHandler)
.and()
.headers()
.frameOptions()
@@ -63,7 +146,15 @@ class SecurityConfig(
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
.addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java)
.addFilterBefore(
ExceptionHandlerFilter(
objectMapper,
aiCharacterAdminRequestMatcher,
aiCharacterAdminSecurityErrorHandler,
aiCharacterAdminExceptionHandler
),
JwtFilter::class.java
)
.authorizeRequests()
.antMatchers("/member/check/email").permitAll()
.antMatchers("/member/check/nickname").permitAll()
@@ -108,10 +199,20 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
.antMatchers(AI_CHARACTER_ADMIN_PATH)
.access(
"hasRole('ADMIN') and " +
"principal instanceof T(kr.co.vividnext.sodalive.member.MemberAdapter) and " +
"principal.member.role == T(kr.co.vividnext.sodalive.member.MemberRole).ADMIN"
)
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
.anyRequest().authenticated()
.and()
.build()
}
companion object {
private const val AI_CHARACTER_ADMIN_PATH = "/api/v2/admin/ai-characters/**"
}
}

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.configs
import kr.co.vividnext.sodalive.common.CountryInterceptor
import kr.co.vividnext.sodalive.i18n.LangInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@@ -18,15 +19,49 @@ class WebConfig(
}
override fun addCorsMappings(registry: CorsRegistry) {
listOf("/admin/member/login", "/member/logout").forEach { path ->
registry.addMapping(path)
.allowedOrigins(*AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS.toTypedArray())
.allowedMethods("*")
.allowCredentials(true)
}
registry.addMapping("/api/v2/admin/ai-characters/**")
.allowedOrigins(*AI_CHARACTER_ADMIN_ALLOWED_ORIGINS.toTypedArray())
.allowedMethods("*")
.allowCredentials(true)
registry.addMapping("/**")
.allowedOrigins(
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
)
.allowedOrigins(*ALLOWED_ORIGINS.toTypedArray())
.allowedMethods("*")
.allowCredentials(true)
}
companion object {
private val ALLOWED_ORIGINS = listOf(
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
)
private val AI_CHARACTER_ADMIN_ALLOWED_ORIGINS = listOf(
"http://localhost:8888",
"https://test-character-admin.sodalive.net",
"https://character-admin.sodalive.net"
)
private val AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS =
(ALLOWED_ORIGINS + AI_CHARACTER_ADMIN_ALLOWED_ORIGINS).distinct()
internal fun createAiCharacterAdminCorsConfiguration(): CorsConfiguration {
return CorsConfiguration().apply {
applyPermitDefaultValues()
allowedOrigins = AI_CHARACTER_ADMIN_ALLOWED_ORIGINS
allowedMethods = listOf(CorsConfiguration.ALL)
allowCredentials = true
}
}
}
}

View File

@@ -83,15 +83,21 @@ class TokenProvider(
.parseClaimsJws(token)
.body
val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) }
val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong())
val memberId = claims.subject?.toLongOrNull()
?: throw SodaException(messageKey = "common.error.bad_credentials")
val authorityNames = (claims[AUTHORITIES_KEY] as? String)
?.split(",")
?.takeIf { names -> names.all { it.isNotBlank() } }
?: throw SodaException(messageKey = "common.error.bad_credentials")
val authorities = authorityNames.map { SimpleGrantedAuthority(it) }
val memberToken = tokenRepository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (!memberToken.tokenSet.contains(token)) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
val member = repository.findByIdOrNull(id = claims.subject.toLong())
val member = repository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val principal = MemberAdapter(member)

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AiCharacterAdminTargetResolver(
private val chatCharacterRepository: ChatCharacterRepository
) {
@Transactional(readOnly = true)
fun resolve(characterId: Long): AiCharacterAdminTarget {
val chatCharacter = chatCharacterRepository.findByIdWithCreatorMember(characterId)
?: throw invalidTarget()
val creatorMember = chatCharacter.creatorMember
if (creatorMember?.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) {
throw invalidTarget()
}
return AiCharacterAdminTarget(
characterId = characterId,
chatCharacter = chatCharacter,
creatorMember = creatorMember
)
}
private fun invalidTarget(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
data class AiCharacterAdminTarget(
val characterId: Long,
val chatCharacter: ChatCharacter,
val creatorMember: Member
)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import org.springframework.http.HttpStatus
class AiCharacterAdminApiException(
val status: HttpStatus,
val messageKey: String
) : RuntimeException(messageKey)

View File

@@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminErrorResponseWriter(
private val objectMapper: ObjectMapper,
private val messageSource: SodaMessageSource
) {
fun createResponse(request: HttpServletRequest, messageKey: String): ApiResponse<Any> {
val lang = Lang.fromAcceptLanguage(request.getHeader(HttpHeaders.ACCEPT_LANGUAGE))
val message = messageSource.getMessage(messageKey, lang)
?: messageSource.getMessage("common.error.unknown", lang)
return ApiResponse.error(message = message)
}
fun write(
request: HttpServletRequest,
response: HttpServletResponse,
status: HttpStatus,
messageKey: String
) {
response.status = status.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
response.writer.write(objectMapper.writeValueAsString(createResponse(request, messageKey)))
}
}

View File

@@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.validation.BindException
import org.springframework.web.HttpMediaTypeNotAcceptableException
import org.springframework.web.HttpMediaTypeNotSupportedException
import org.springframework.web.HttpRequestMethodNotSupportedException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.MissingPathVariableException
import org.springframework.web.bind.ServletRequestBindingException
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.multipart.MultipartException
import org.springframework.web.multipart.support.MissingServletRequestPartException
import org.springframework.web.servlet.HandlerExceptionResolver
import org.springframework.web.servlet.ModelAndView
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminExceptionHandler(
private val responseWriter: AiCharacterAdminErrorResponseWriter,
private val requestMatcher: RequestMatcher
) : HandlerExceptionResolver, Ordered {
private val logger = LoggerFactory.getLogger(this::class.java)
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
override fun resolveException(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any?,
exception: Exception
): ModelAndView? {
if (!requestMatcher.matches(request)) return null
val error = resolveError(exception)
if (error.status.is5xxServerError) {
logger.error("Unexpected AI character admin API error", exception)
} else {
logger.debug("AI character admin API request error", exception)
}
writeStandardHeaders(request, response, exception)
responseWriter.write(request, response, error.status, error.messageKey)
return ModelAndView()
}
private fun resolveError(exception: Exception): ResolvedError {
return when (exception) {
is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey)
is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied")
is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED)
is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
is HttpMediaTypeNotAcceptableException -> invalidRequest(HttpStatus.NOT_ACCEPTABLE)
is MissingPathVariableException -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
is BindException,
is HttpMessageNotReadableException,
is MethodArgumentNotValidException,
is MethodArgumentTypeMismatchException,
is ServletRequestBindingException,
is MultipartException,
is MissingServletRequestPartException -> invalidRequest(HttpStatus.BAD_REQUEST)
else -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
}
}
private fun writeStandardHeaders(
request: HttpServletRequest,
response: HttpServletResponse,
exception: Exception
) {
when (exception) {
is HttpRequestMethodNotSupportedException -> {
exception.supportedMethods?.let { response.setHeader(HttpHeaders.ALLOW, it.joinToString(", ")) }
}
is HttpMediaTypeNotSupportedException -> {
if (exception.supportedMediaTypes.isNotEmpty()) {
val supportedMediaTypes = MediaType.toString(exception.supportedMediaTypes)
response.setHeader(HttpHeaders.ACCEPT, supportedMediaTypes)
if (request.method == HttpMethod.PATCH.name) {
response.setHeader(HttpHeaders.ACCEPT_PATCH, supportedMediaTypes)
}
}
}
}
}
private fun invalidRequest(status: HttpStatus): ResolvedError {
return ResolvedError(status, "common.error.invalid_request")
}
private data class ResolvedError(
val status: HttpStatus,
val messageKey: String
)
}

View File

@@ -0,0 +1,83 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.firewall.DefaultRequestRejectedHandler
import org.springframework.security.web.firewall.RequestRejectedException
import org.springframework.security.web.firewall.RequestRejectedHandler
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.DefaultCorsProcessor
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletRequestWrapper
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminSecurityErrorHandler(
private val responseWriter: AiCharacterAdminErrorResponseWriter,
private val aiCharacterAdminRequestMatcher: RequestMatcher,
private val aiCharacterAdminCorsConfiguration: CorsConfiguration
) : AuthenticationEntryPoint, AccessDeniedHandler, RequestRejectedHandler {
private val defaultRequestRejectedHandler = DefaultRequestRejectedHandler()
private val corsProcessor = DefaultCorsProcessor()
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
responseWriter.write(
request = request,
response = response,
status = HttpStatus.UNAUTHORIZED,
messageKey = "common.error.bad_credentials"
)
}
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
responseWriter.write(
request = request,
response = response,
status = HttpStatus.FORBIDDEN,
messageKey = "common.error.access_denied"
)
}
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
requestRejectedException: RequestRejectedException
) {
if (!aiCharacterAdminRequestMatcher.matches(request)) {
defaultRequestRejectedHandler.handle(request, response, requestRejectedException)
return
}
// Spring 5.3 CORS processor only recognizes HttpMethod enums, so use GET for nonstandard method checks.
val corsRequest = if (HttpMethod.resolve(request.method) == null) {
object : HttpServletRequestWrapper(request) {
override fun getMethod(): String = HttpMethod.GET.name
}
} else {
request
}
if (!corsProcessor.processRequest(aiCharacterAdminCorsConfiguration, corsRequest, response)) {
return
}
responseWriter.write(
request = request,
response = response,
status = HttpStatus.BAD_REQUEST,
messageKey = "common.error.invalid_request"
)
}
}

View File

@@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.jwt
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.token.MemberToken
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import java.util.Base64
import java.util.Date
import java.util.Optional
class TokenProviderTest {
private lateinit var tokenProvider: TokenProvider
private lateinit var memberRepository: MemberRepository
private lateinit var tokenRepository: MemberTokenRepository
@BeforeEach
fun setUp() {
memberRepository = Mockito.mock(MemberRepository::class.java)
tokenRepository = Mockito.mock(MemberTokenRepository::class.java)
tokenProvider = TokenProvider(
secret = SECRET,
tokenValidityInSeconds = 3600,
repository = memberRepository,
tokenRepository = tokenRepository
)
tokenProvider.afterPropertiesSet()
}
@Test
@DisplayName("subject가 없는 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithoutSubjectAsBadCredentials() {
val token = signedToken(subject = null)
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@Test
@DisplayName("숫자가 아닌 subject의 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithNonNumericSubjectAsBadCredentials() {
val token = signedToken(subject = "not-a-number")
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@Test
@DisplayName("auth claim이 없는 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithoutAuthoritiesClaimAsBadCredentials() {
val token = signedToken(subject = MEMBER_ID.toString(), authorities = null)
allowStoredToken(token)
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@ParameterizedTest
@ValueSource(strings = ["", " ", "ROLE_ADMIN,", ",ROLE_ADMIN", "ROLE_ADMIN,,ROLE_USER"])
@DisplayName("비어 있는 authority를 포함한 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithBlankAuthorityAsBadCredentials(authorities: String) {
val token = signedToken(subject = MEMBER_ID.toString(), authorities = authorities)
allowStoredToken(token)
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@Test
@DisplayName("문자열이 아닌 auth claim의 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithNonStringAuthoritiesClaimAsBadCredentials() {
val token = signedToken(subject = MEMBER_ID.toString(), authorities = 1)
allowStoredToken(token)
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
private fun allowStoredToken(token: String) {
val member = Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
).apply { id = MEMBER_ID }
Mockito.`when`(tokenRepository.findById(MEMBER_ID))
.thenReturn(Optional.of(MemberToken(MEMBER_ID, mutableSetOf(token))))
Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member))
}
private fun signedToken(subject: String?, authorities: Any? = "ROLE_ADMIN"): String {
val builder = Jwts.builder()
.signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET)), SignatureAlgorithm.HS512)
.setExpiration(Date(System.currentTimeMillis() + 60_000))
if (subject != null) {
builder.setSubject(subject)
}
if (authorities != null) {
builder.claim("auth", authorities)
}
return builder.compact()
}
companion object {
private const val MEMBER_ID = 1L
private val SECRET = Base64.getEncoder().encodeToString("a".repeat(64).toByteArray())
}
}

View File

@@ -0,0 +1,79 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestComponent
import org.springframework.context.annotation.Import
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Import(
AiCharacterAdminAccessDeniedErrorContractTest.AiCharacterAdminAccessDeniedSampleController::class
)
class AiCharacterAdminAccessDeniedErrorContractTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@ParameterizedTest
@CsvSource(
"ko,권한이 없습니다.",
"en,You do not have permission.",
"ja,権限がありません。"
)
@DisplayName("신규 prefix controller AccessDeniedException은 403 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForControllerAccessDenied(language: String, message: String) {
mockMvc.perform(
get("/api/v2/admin/ai-characters/access-denied-contract")
.header("Accept-Language", language)
.with(authentication(createAuthentication()))
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.errorProperty").doesNotExist())
}
private fun createAuthentication() =
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
@TestComponent
@RestController
class AiCharacterAdminAccessDeniedSampleController {
@GetMapping("/api/v2/admin/ai-characters/access-denied-contract")
fun accessDenied(): ApiResponse<String> {
throw AccessDeniedException("admin only")
}
}
}

View File

@@ -0,0 +1,130 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
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.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestComponent
import org.springframework.context.annotation.Import
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Import(
AiCharacterAdminAuthorizationTest.AiCharacterAdminAuthorizationSampleController::class,
AiCharacterAdminAuthorizationTest.AiCharacterAdminLegacySampleController::class
)
class AiCharacterAdminAuthorizationTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@Test
@DisplayName("AI 캐릭터 관리자 API는 비회원 요청을 거부한다")
fun shouldRejectAnonymousUser() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("AI 캐릭터 관리자 API는 JWT role이 ADMIN이 아니면 현재 DB role이 ADMIN이어도 거부한다")
fun shouldRejectNonAdminJwtRole() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
)
.andExpect(status().isForbidden)
}
@Test
@DisplayName("AI 캐릭터 관리자 API는 JWT가 ADMIN이어도 현재 DB role이 강등되면 거부한다")
fun shouldRejectStaleAdminClaimAfterCurrentRoleDowngrade() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
)
.andExpect(status().isForbidden)
}
@Test
@DisplayName("AI 캐릭터 관리자 API는 ADMIN authority가 있어도 MemberAdapter principal이 아니면 거부한다")
fun shouldRejectAdminAuthorityWithoutMemberAdapterPrincipal() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isForbidden)
}
@Test
@DisplayName("AI 캐릭터 관리자 API는 JWT와 현재 DB role이 모두 ADMIN이면 허용한다")
fun shouldAllowCurrentAdminWithAdminJwtRole() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").value("ok"))
}
@Test
@DisplayName("현재 DB role 추가 검증은 신규 AI 캐릭터 관리자 prefix 밖에 적용하지 않는다")
fun shouldKeepLegacyAuthenticatedRuleOutsideAiCharacterAdminPrefix() {
mockMvc.perform(
get("/phase1-legacy-sample")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
}
private fun createAuthentication(jwtRole: MemberRole, currentRole: MemberRole) =
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = currentRole
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}"))
)
@TestComponent
@RestController
@RequestMapping("/api/v2/admin/ai-characters/sample")
class AiCharacterAdminAuthorizationSampleController {
@GetMapping
fun sample(): ApiResponse<String> = ApiResponse.ok("ok")
}
@TestComponent
@RestController
class AiCharacterAdminLegacySampleController {
@GetMapping("/phase1-legacy-sample")
fun sample(): ApiResponse<String> = ApiResponse.ok("legacy-ok")
}
}

View File

@@ -0,0 +1,907 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import io.jsonwebtoken.Claims
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Header
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestComponent
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.core.MethodParameter
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.security.authentication.InternalAuthenticationServiceException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.validation.BeanPropertyBindingResult
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MaxUploadSizeExceededException
import org.springframework.web.multipart.MultipartException
import org.springframework.web.multipart.support.MissingServletRequestPartException
@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Import(
AiCharacterAdminErrorContractTest.AiCharacterAdminErrorContractSampleController::class,
AiCharacterAdminErrorContractTest.AiCharacterAdminLegacyErrorContractSampleController::class
)
class AiCharacterAdminErrorContractTest @Autowired constructor(
private val mockMvc: MockMvc
) {
companion object {
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
private const val CREATOR_ORIGIN = "https://creator.sodalive.net"
}
@MockBean
private lateinit var tokenProvider: TokenProvider
@MockBean
private lateinit var chatCharacterRepository: ChatCharacterRepository
@ParameterizedTest
@CsvSource(
"ko,로그인 정보를 확인해주세요.",
"en,Please check your login information.",
"ja,ログイン情報を確認してください。"
)
@DisplayName("비회원 401 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForAnonymousRequest(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Accept-Language", language)
.with(anonymous())
)
result.andExpectApiError(401, message)
}
@ParameterizedTest
@CsvSource(
"ko,권한이 없습니다.",
"en,You do not have permission.",
"ja,権限がありません。"
)
@DisplayName("비ADMIN 403 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForNonAdminRequest(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
)
result.andExpectApiError(403, message)
}
@ParameterizedTest
@CsvSource(
"ko,권한이 없습니다.",
"en,You do not have permission.",
"ja,権限がありません。"
)
@DisplayName("stale ADMIN claim 403 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForStaleAdminClaim(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
)
result.andExpectApiError(403, message)
}
@ParameterizedTest
@CsvSource(
"ko,로그인 정보를 확인해주세요.",
"en,Please check your login information.",
"ja,ログイン情報を確認してください。"
)
@DisplayName("잘못된 JWT의 401 오류도 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForInvalidJwt(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer invalid-token")
.header("Accept-Language", language)
)
result.andExpectApiError(401, message)
}
@ParameterizedTest
@CsvSource(
"ko,로그인 정보를 확인해주세요.",
"en,Please check your login information.",
"ja,ログイン情報を確認してください。"
)
@DisplayName("폐기된 JWT filter 예외도 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForRevokedJwt(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("revoked-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("revoked-token"))
.thenThrow(SodaException(messageKey = "common.error.bad_credentials"))
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer revoked-token")
.header("Accept-Language", language)
)
result.andExpectApiError(401, message)
}
@ParameterizedTest
@CsvSource(
"ko,로그인 정보를 확인해주세요.",
"en,Please check your login information.",
"ja,ログイン情報を確認してください。"
)
@DisplayName("JWT 검증 직후 만료된 token도 401 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseWhenJwtExpiresBetweenParses(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("expired-between-parses")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("expired-between-parses"))
.thenThrow(
ExpiredJwtException(
Mockito.mock(Header::class.java),
Mockito.mock(Claims::class.java),
"expired"
)
)
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer expired-between-parses")
.header("Accept-Language", language)
)
result.andExpectApiError(401, message)
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("인증 저장소 장애는 500 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForAuthenticationInfrastructureError(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("authentication-service-error")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("authentication-service-error"))
.thenThrow(InternalAuthenticationServiceException("authentication backend unavailable"))
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer authentication-service-error")
.header("Accept-Language", language)
)
result.andExpectApiError(500, message)
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("JWT filter의 예상하지 못한 오류는 500 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnexpectedJwtFilterError(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("filter-error-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("filter-error-token"))
.thenThrow(IllegalStateException("internal detail must not be exposed"))
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer filter-error-token")
.header("Accept-Language", language)
)
result.andExpectApiError(500, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("잘못된 target 400 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForInvalidTarget(language: String, message: String) {
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/invalid-target")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("잘못된 request binding은 400 ApiResponse와 i18n 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForInvalidRequestBinding(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/binding")
.param("value", "not-a-number")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("읽을 수 없는 JSON body는 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnreadableJson(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/body-binding")
.contentType(MediaType.APPLICATION_JSON)
.content("{")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
assertEquals(HttpMessageNotReadableException::class.java, result.andReturn().resolvedException?.javaClass)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("MethodArgumentNotValidException은 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMethodArgumentNotValid(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/method-argument-not-valid")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"value\":1}")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
assertEquals(MethodArgumentNotValidException::class.java, result.andReturn().resolvedException?.javaClass)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("필수 multipart part 누락은 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMissingMultipartPart(language: String, message: String) {
val result = mockMvc.perform(
multipart("/api/v2/admin/ai-characters/error-contract/required-part")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
assertEquals(MissingServletRequestPartException::class.java, result.andReturn().resolvedException?.javaClass)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("필수 request header 누락은 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMissingRequiredHeader(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/required-header")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("예상하지 못한 오류는 500 ApiResponse와 i18n 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnexpectedError(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/unexpected")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(500, message)
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("등록되지 않은 message key는 status를 유지하고 unknown 메시지로 fallback한다")
fun shouldFallbackToLocalizedUnknownMessageForMissingMessageKey(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/missing-message-key")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("지원하지 않는 HTTP method는 405 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnsupportedMethod(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/success")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(405, message)
result.andExpect(header().string(HttpHeaders.ALLOW, "GET"))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("지원하지 않는 media type은 415 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnsupportedMediaType(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/media-type")
.contentType(MediaType.TEXT_PLAIN)
.content("text")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(415, message)
result.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
}
@Test
@DisplayName("PATCH의 지원하지 않는 media type은 Accept와 Accept-Patch header를 반환한다")
fun shouldReturnAcceptHeadersForUnsupportedPatchMediaType() {
val result = mockMvc.perform(
patch("/api/v2/admin/ai-characters/error-contract/media-type")
.contentType(MediaType.TEXT_PLAIN)
.content("text")
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(415, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
result.andExpect(header().string(HttpHeaders.ACCEPT_PATCH, MediaType.APPLICATION_JSON_VALUE))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("지원하지 않는 응답 media type은 406 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForNotAcceptableMediaType(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.accept(MediaType.APPLICATION_XML)
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(406, message)
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 응답 media type 406에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToNotAcceptableMediaTypeError() {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.accept(MediaType.APPLICATION_XML)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(406, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("누락된 path variable은 500 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMissingPathVariable(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/missing-path-variable")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(500, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("multipart 파싱 오류는 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMultipartError(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/multipart-error")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("multipart 용량 초과 오류는 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMaxUploadSizeError(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/max-upload-size-error")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("신규 prefix의 미매핑 경로는 404 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnmappedPath(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/unmapped-path")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(404, message)
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 미매핑 경로 404에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToUnmappedPathError() {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(404, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 method 405에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToUnsupportedMethodError() {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/success")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(405, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 media type 415에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToUnsupportedMediaTypeError() {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/media-type")
.contentType(MediaType.TEXT_PLAIN)
.content("text")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(415, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 multipart 오류 400에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToMultipartError() {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/multipart-error")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@ParameterizedTest
@ValueSource(
strings = [
"http://localhost:8888",
"https://test-character-admin.sodalive.net",
"https://character-admin.sodalive.net"
]
)
@DisplayName("캐릭터 관리자 Origin의 신규 prefix preflight를 CORS filter가 허용한다")
fun shouldAllowCorsPreflightForAiCharacterAdminPrefix(origin: String) {
mockMvc.perform(
options("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
}
@ParameterizedTest
@ValueSource(strings = ["POST", "PUT", "PATCH", "DELETE"])
@DisplayName("캐릭터 관리자 Origin의 실제 mapped endpoint write preflight를 CORS filter가 허용한다")
fun shouldAllowCorsPreflightForMappedAiCharacterAdminWriteMethods(method: String) {
mockMvc.perform(
options("/api/v2/admin/ai-characters/error-contract/write-preflight")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, method))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
}
@ParameterizedTest
@ValueSource(strings = ["POST", "PUT", "PATCH", "DELETE"])
@DisplayName("캐릭터 관리자 Origin의 fallback write preflight를 CORS filter가 허용한다")
fun shouldAllowCorsPreflightForFallbackAiCharacterAdminWriteMethods(method: String) {
mockMvc.perform(
options("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, method))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
}
@ParameterizedTest
@ValueSource(
strings = [
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://admin.sodalive.net",
"https://test-admin.sodalive.net"
]
)
@DisplayName("신규 prefix preflight는 범용 관리자와 creator Origin을 허용하지 않는다")
fun shouldRejectOtherAdminOriginPreflightForAiCharacterAdminPrefix(origin: String) {
mockMvc.perform(
options("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("신규 prefix 일반 요청도 creator Origin이면 CORS 단계에서 거부된다")
fun shouldNotApplyCreatorOriginCorsToAiCharacterAdminError() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, CREATOR_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("비ADMIN 요청은 request body 역직렬화 전에 403으로 거부된다")
fun shouldRejectNonAdminBeforeRequestBodyBinding() {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/body-binding")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"value\":\"not-a-number\"}")
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
)
result.andExpectApiError(403, "You do not have permission.")
}
@Test
@DisplayName("인접 prefix에는 AI 캐릭터 관리자 전용 fallback handler가 적용되지 않는다")
fun shouldNotApplyAiCharacterAdminHandlerToAdjacentPrefix() {
mockMvc.perform(
delete("/api/v2/admin/ai-characters-shadow")
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
.andExpect(status().isNotFound)
.andExpect(content().string(""))
}
@Test
@DisplayName("Accept-Language가 없으면 신규 security 오류 메시지는 한국어로 fallback한다")
fun shouldFallbackToKoreanForMissingAcceptLanguage() {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.with(anonymous())
)
result.andExpectApiError(401, "로그인 정보를 확인해주세요.")
}
@Test
@DisplayName("지원하지 않는 Accept-Language는 신규 MVC 오류 메시지를 한국어로 fallback한다")
fun shouldFallbackToKoreanForUnsupportedAcceptLanguage() {
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/invalid-target")
.header("Accept-Language", "fr")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, "잘못된 요청입니다.")
}
@Test
@DisplayName("신규 prefix 밖 비회원 오류는 기존 빈 401 응답을 유지한다")
fun shouldKeepLegacyAnonymousErrorResponse() {
mockMvc.perform(
get("/phase1-legacy-error-contract")
.header("Accept-Language", "en")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
.andExpect(content().string(""))
}
@Test
@DisplayName("신규 prefix 밖 JWT filter 예외는 기존 한국어 ApiResponse를 유지한다")
fun shouldKeepLegacyRevokedJwtErrorResponse() {
Mockito.`when`(tokenProvider.validateToken("legacy-revoked-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("legacy-revoked-token"))
.thenThrow(SodaException(messageKey = "common.error.bad_credentials"))
mockMvc.perform(
get("/phase1-legacy-error-contract")
.header("Authorization", "Bearer legacy-revoked-token")
.header("Accept-Language", "en")
)
.andExpect(status().isUnauthorized)
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요."))
}
@Test
@DisplayName("신규 prefix 밖 지원하지 않는 HTTP method 오류는 기존 200 응답을 유지한다")
fun shouldKeepLegacyUnsupportedMethodErrorResponse() {
mockMvc.perform(
post("/phase1-legacy-error-contract")
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
.andExpect(status().isOk)
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."))
}
private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) {
andExpect(status().`is`(httpStatus))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(jsonPath("$.data").value(nullValue()))
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
}
private fun createAuthentication(jwtRole: MemberRole, currentRole: MemberRole) =
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = currentRole
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}"))
)
@TestComponent
@RestController
@RequestMapping("/api/v2/admin/ai-characters/error-contract")
class AiCharacterAdminErrorContractSampleController(
private val targetResolver: AiCharacterAdminTargetResolver
) {
@GetMapping("/success")
fun success(): ApiResponse<String> = ApiResponse.ok("ok")
@GetMapping("/invalid-target")
fun invalidTarget(): ApiResponse<String> {
targetResolver.resolve(404L)
return ApiResponse.ok("unexpected")
}
@GetMapping("/binding")
fun binding(@RequestParam value: Long): ApiResponse<Long> = ApiResponse.ok(value)
@GetMapping("/required-header")
fun requiredHeader(@RequestHeader("X-Required") value: String): ApiResponse<String> = ApiResponse.ok(value)
@GetMapping("/unexpected")
fun unexpected(): ApiResponse<String> {
throw IllegalStateException("internal detail must not be exposed")
}
@GetMapping("/missing-message-key")
fun missingMessageKey(): ApiResponse<String> {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "missing.message.key")
}
@PostMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun mediaType(): ApiResponse<String> = ApiResponse.ok("ok")
@PatchMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun patchMediaType(): ApiResponse<String> = ApiResponse.ok("ok")
@PostMapping("/write-preflight")
fun postWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
@PutMapping("/write-preflight")
fun putWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
@PatchMapping("/write-preflight")
fun patchWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
@DeleteMapping("/write-preflight")
fun deleteWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
@GetMapping("/missing-path-variable")
fun missingPathVariable(@PathVariable("missing") value: Long): ApiResponse<Long> = ApiResponse.ok(value)
@PostMapping("/body-binding", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun bodyBinding(@RequestBody request: BodyBindingRequest): ApiResponse<Long> = ApiResponse.ok(request.value)
@PostMapping("/method-argument-not-valid", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun methodArgumentNotValid(@RequestBody request: BodyBindingRequest): ApiResponse<Long> {
val method = AiCharacterAdminErrorContractSampleController::class.java
.getDeclaredMethod("methodArgumentNotValid", BodyBindingRequest::class.java)
val bindingResult = BeanPropertyBindingResult(request, "request")
bindingResult.rejectValue("value", "invalid")
throw MethodArgumentNotValidException(MethodParameter(method, 0), bindingResult)
}
@PostMapping("/required-part", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun requiredPart(@RequestPart("file") value: String): ApiResponse<String> = ApiResponse.ok(value)
@PostMapping("/multipart-error")
fun multipartError(): ApiResponse<String> {
throw MultipartException("malformed multipart request")
}
@PostMapping("/max-upload-size-error")
fun maxUploadSizeError(): ApiResponse<String> {
throw MaxUploadSizeExceededException(1L)
}
}
@TestComponent
@RestController
class AiCharacterAdminLegacyErrorContractSampleController {
@GetMapping("/phase1-legacy-error-contract")
fun sample(): ApiResponse<String> = ApiResponse.ok("legacy-ok")
}
}
data class BodyBindingRequest(
val value: Long
)

View File

@@ -0,0 +1,298 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import com.jayway.jsonpath.JsonPath
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestComponent
import org.springframework.context.annotation.Import
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.web.firewall.RequestRejectedException
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.net.URI
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@Import(AiCharacterAdminLoginJwtIntegrationTest.AiCharacterAdminLoginJwtSampleController::class)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminLoginJwtIntegrationTest @Autowired constructor(
private val mockMvc: MockMvc,
private val memberRepository: MemberRepository,
private val memberTokenRepository: MemberTokenRepository,
private val passwordEncoder: PasswordEncoder
) {
companion object {
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
private const val GENERAL_ADMIN_ORIGIN = "https://admin.sodalive.net"
private const val UNTRUSTED_ORIGIN = "https://not-allowed.example.com"
}
@AfterEach
fun cleanUpMemberTokens() {
memberTokenRepository.deleteAll()
}
@Test
@DisplayName("관리자 로그인 JWT는 신규 AI 캐릭터 관리자 prefix를 통과하고 현재 DB role 강등 시 거부된다")
fun shouldAuthorizeAiCharacterAdminPrefixWithActualAdminLoginJwtAndCurrentRole() {
val admin = memberRepository.saveAndFlush(createMember(MemberRole.ADMIN))
val token = loginAdmin()
mockMvc.perform(
get("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").value("ok"))
admin.role = MemberRole.USER
memberRepository.saveAndFlush(admin)
mockMvc.perform(
get("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isForbidden)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
}
@Test
@DisplayName("실제 Spring context에서 신규 prefix 비회원 오류는 localized ApiResponse 401이다")
fun shouldReturnLocalizedUnauthorizedApiResponseInSpringContext() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.with(anonymous())
)
.andExpect(status().isUnauthorized)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("Please check your login information."))
}
@Test
@DisplayName("실제 Spring context에서 신규 prefix는 캐릭터 관리자 Origin preflight만 허용한다")
fun shouldAllowOnlyCharacterAdminOriginPreflightInSpringContext() {
mockMvc.perform(
options("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
mockMvc.perform(
options("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.ORIGIN, GENERAL_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@ParameterizedTest
@ValueSource(
strings = [
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
GENERAL_ADMIN_ORIGIN,
"https://test-character-admin.sodalive.net",
CHARACTER_ADMIN_ORIGIN
]
)
@DisplayName("공유 관리자 로그인과 로그아웃은 기존 관리자와 캐릭터 관리자 Origin을 허용한다")
fun shouldAllowAdminOriginsForSharedAuthenticationEndpoints(origin: String) {
mockMvc.perform(
options("/admin/member/login")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
mockMvc.perform(
options("/member/logout")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
}
@Test
@DisplayName("공유 관리자 로그인과 로그아웃은 미등록 Origin을 거부한다")
fun shouldRejectUntrustedOriginForSharedAuthenticationEndpoints() {
listOf("/admin/member/login", "/member/logout").forEach { path ->
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, UNTRUSTED_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
}
@Test
@DisplayName("캐릭터 관리자 Origin은 공유 인증 외 legacy/public 경로로 확산되지 않는다")
fun shouldNotApplyCharacterAdminCorsToOtherEndpoints() {
listOf("/api/v2/home/recommendations/ai-characters", "/api/v2/admin/ai-characters-shadow").forEach { path ->
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
}
@Test
@DisplayName("캐릭터 관리자 Origin에서 기존 관리자 로그인과 공통 로그아웃을 호출한다")
fun shouldLoginAndLogoutFromCharacterAdminOrigin() {
memberRepository.saveAndFlush(createMember(MemberRole.ADMIN))
val token = loginAdmin()
mockMvc.perform(
post("/member/logout")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
mockMvc.perform(
get("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isUnauthorized)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("Please check your login information."))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 비표준 method는 localized 400 ApiResponse와 CORS를 반환한다")
fun shouldReturnLocalizedBadRequestForRejectedMethodFromCharacterAdminOrigin() {
mockMvc.perform(
request("PROPFIND", URI.create("/api/v2/admin/ai-characters/login-jwt/success"))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isBadRequest)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("Invalid request."))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 위험 URL은 400 ApiResponse와 CORS를 반환한다")
fun shouldReturnBadRequestForRejectedDangerousUrlFromCharacterAdminOrigin() {
mockMvc.perform(
request("GET", URI.create("/api/v2/admin/ai-characters//login-jwt/success"))
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isBadRequest)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("미허용 Origin의 신규 prefix 비표준 method는 CORS 정책 403으로 종료한다")
fun shouldRejectRejectedMethodFromUntrustedOriginWithoutCorsHeader() {
mockMvc.perform(
request("PROPFIND", URI.create("/api/v2/admin/ai-characters/login-jwt/success"))
.header(HttpHeaders.ORIGIN, UNTRUSTED_ORIGIN)
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("legacy 경로의 비표준 method는 기존 firewall fallback을 유지한다")
fun shouldKeepLegacyFirewallFallbackForRejectedMethod() {
assertThrows<RequestRejectedException> {
mockMvc.perform(request("PROPFIND", URI.create("/api/v2/home/recommendations")))
}
}
private fun loginAdmin(): String {
val result = mockMvc.perform(
post("/admin/member/login")
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.content("""{"email":"admin-jwt@test.com","password":"password"}""")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(jsonPath("$.success").value(true))
.andReturn()
return JsonPath.read(result.response.contentAsString, "$.data.token")
}
private fun createMember(role: MemberRole): Member {
return Member(
email = "admin-jwt@test.com",
password = passwordEncoder.encode("password"),
nickname = "admin-jwt",
role = role
)
}
@TestComponent
@RestController
@RequestMapping("/api/v2/admin/ai-characters/login-jwt")
class AiCharacterAdminLoginJwtSampleController {
@GetMapping("/success")
fun success(): ApiResponse<String> = ApiResponse.ok("ok")
}
}

View File

@@ -0,0 +1,152 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.hibernate.Hibernate
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpStatus
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
import javax.persistence.EntityManagerFactory
@SpringBootTest(properties = ["spring.jpa.properties.hibernate.generate_statistics=true"])
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminTargetResolverIntegrationTest @Autowired constructor(
private val resolver: AiCharacterAdminTargetResolver,
private val memberRepository: MemberRepository,
private val chatCharacterRepository: ChatCharacterRepository,
private val entityManager: EntityManager,
private val entityManagerFactory: EntityManagerFactory
) {
@Test
@DisplayName("요청한 캐릭터와 연결된 AI 캐릭터 Member를 실제 DB에서 해석한다")
fun shouldResolveRequestedCharacterCreatorMemberFromDatabase() {
val requestedMember = memberRepository.save(createMember("requested"))
val otherMember = memberRepository.save(createMember("other"))
val requestedCharacter = chatCharacterRepository.save(createCharacter("requested", requestedMember))
val otherCharacter = chatCharacterRepository.save(createCharacter("other", otherMember))
entityManager.flush()
entityManager.clear()
val target = resolver.resolve(requestedCharacter.id!!)
assertEquals(requestedCharacter.id, target.characterId)
assertEquals(requestedCharacter.id, target.chatCharacter.id)
assertEquals(requestedMember.id, target.creatorMember.id)
assertEquals(otherCharacter.id, chatCharacterRepository.findByCreatorMemberId(otherMember.id!!)?.id)
}
@Test
@DisplayName("캐릭터 조회 시 creatorMember를 함께 조회한다")
fun shouldFetchCreatorMemberWithCharacter() {
val member = memberRepository.save(createMember("fetch"))
val character = chatCharacterRepository.save(createCharacter("fetch", member))
entityManager.flush()
entityManager.clear()
val found = chatCharacterRepository.findByIdWithCreatorMember(character.id!!)
assertEquals(character.id, found?.id)
assertTrue(found != null && Hibernate.isInitialized(found.creatorMember))
assertEquals(member.id, found?.creatorMember?.id)
}
@Test
@DisplayName("유효하지 않은 creator를 거부하고 DB insert/update/delete를 만들지 않는다")
fun shouldRejectInvalidCreatorWithoutDatabaseMutation() {
val humanMember = memberRepository.save(createMember("human", memberKind = MemberKind.HUMAN))
val character = chatCharacterRepository.save(createCharacter("human", humanMember))
entityManager.flush()
entityManager.clear()
val memberCount = memberRepository.count()
val characterCount = chatCharacterRepository.count()
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(character.id!!)
}
entityManager.flush()
entityManager.clear()
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(0L, statistics.entityInsertCount)
assertEquals(0L, statistics.entityUpdateCount)
assertEquals(0L, statistics.entityDeleteCount)
assertEquals(memberCount, memberRepository.count())
assertEquals(characterCount, chatCharacterRepository.count())
assertEquals(MemberKind.HUMAN, memberRepository.findById(humanMember.id!!).orElseThrow().memberKind)
}
@Test
@DisplayName("AI 캐릭터 Member라도 CREATOR role이 아니면 거부하고 DB를 변경하지 않는다")
fun shouldRejectWrongRoleAiCharacterMemberWithoutDatabaseMutation() {
val userMember = memberRepository.save(createMember("user", role = MemberRole.USER))
val character = chatCharacterRepository.save(createCharacter("user", userMember))
entityManager.flush()
entityManager.clear()
val memberCount = memberRepository.count()
val characterCount = chatCharacterRepository.count()
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(character.id!!)
}
entityManager.flush()
entityManager.clear()
val savedMember = memberRepository.findById(userMember.id!!).orElseThrow()
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(0L, statistics.entityInsertCount)
assertEquals(0L, statistics.entityUpdateCount)
assertEquals(0L, statistics.entityDeleteCount)
assertEquals(memberCount, memberRepository.count())
assertEquals(characterCount, chatCharacterRepository.count())
assertEquals(MemberRole.USER, savedMember.role)
assertEquals(MemberKind.AI_CHARACTER, savedMember.memberKind)
}
private fun createCharacter(name: String, creatorMember: Member): ChatCharacter {
return ChatCharacter(
characterUUID = "character-$name-${System.nanoTime()}",
name = "character-$name",
description = "description-$name",
systemPrompt = "system-prompt-$name"
).apply {
this.creatorMember = creatorMember
}
}
private fun createMember(
name: String,
role: MemberRole = MemberRole.CREATOR,
memberKind: MemberKind = MemberKind.AI_CHARACTER
): Member {
return Member(
email = if (memberKind == MemberKind.HUMAN) "$name@example.com" else null,
password = if (memberKind == MemberKind.HUMAN) "password" else "",
nickname = "member-$name",
role = role,
memberKind = memberKind
)
}
}

View File

@@ -0,0 +1,145 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.http.HttpStatus
class AiCharacterAdminTargetResolverTest {
private val chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java)
private val resolver = AiCharacterAdminTargetResolver(chatCharacterRepository)
@Test
@DisplayName("유효한 AI 캐릭터 target에서 creatorMember를 해석한다")
fun shouldResolveAiCharacterCreatorMemberForAdminTarget() {
val creatorMember = createMember(id = 9001L)
val character = createCharacter(id = 101L, creatorMember = creatorMember)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)).thenReturn(character)
val target = resolver.resolve(101L)
assertEquals(101L, target.characterId)
assertSame(character, target.chatCharacter)
assertSame(creatorMember, target.creatorMember)
}
@Test
@DisplayName("존재하지 않는 캐릭터를 부작용 없이 거부한다")
fun shouldRejectMissingCharacterWithoutSideEffect() {
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(404L)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(404L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
@Test
@DisplayName("CREATOR가 아닌 creatorMember를 부작용 없이 거부한다")
fun shouldRejectWrongRoleWithoutSideEffect() {
val member = createMember(id = 9001L, role = MemberRole.USER)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
.thenReturn(createCharacter(id = 101L, creatorMember = member))
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(101L)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
@Test
@DisplayName("HUMAN creatorMember를 부작용 없이 거부한다")
fun shouldRejectHumanCreatorMemberWithoutSideEffect() {
val member = createMember(id = 9001L, memberKind = MemberKind.HUMAN)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
.thenReturn(createCharacter(id = 101L, creatorMember = member))
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(101L)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
@Test
@DisplayName("creatorMember가 없는 캐릭터를 부작용 없이 거부한다")
fun shouldRejectMissingCreatorMemberWithoutSideEffect() {
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
.thenReturn(createCharacter(id = 101L, creatorMember = null))
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(101L)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
@Test
@DisplayName("여러 캐릭터 fixture 중 요청한 캐릭터만 해석한다")
fun shouldResolveOnlyRequestedCharacterFixture() {
val creatorMember = createMember(id = 9001L)
val otherCreatorMember = createMember(id = 9002L)
val requestedCharacter = createCharacter(id = 101L, creatorMember = creatorMember)
val otherCharacter = createCharacter(id = 202L, creatorMember = otherCreatorMember)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)).thenReturn(requestedCharacter)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(202L)).thenReturn(otherCharacter)
val target = resolver.resolve(101L)
assertEquals(101L, target.characterId)
assertEquals(9001L, target.creatorMember.id)
assertSame(requestedCharacter, target.chatCharacter)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
private fun createCharacter(id: Long, creatorMember: Member?): ChatCharacter {
return ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
).apply {
this.id = id
this.creatorMember = creatorMember
}
}
private fun createMember(
id: Long,
role: MemberRole = MemberRole.CREATOR,
memberKind: MemberKind = MemberKind.AI_CHARACTER
): Member {
return Member(
email = if (memberKind == MemberKind.HUMAN) "human-$id@example.com" else null,
password = "password",
nickname = "member-$id",
role = role,
memberKind = memberKind
).apply { this.id = id }
}
}