diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index bd55f234..b55e35f5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -188,6 +188,7 @@ class SecurityConfig( .antMatchers("/live/recommend/channel").permitAll() .antMatchers(HttpMethod.GET, "/live/room").permitAll() .antMatchers(HttpMethod.GET, "/event").permitAll() + .antMatchers(HttpMethod.GET, "/api/v2/events/*").permitAll() .antMatchers(HttpMethod.GET, "/live/recommend").permitAll() .antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt new file mode 100644 index 00000000..8efc945d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.v2.api.event.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.event.application.EventDetailService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/events") +class EventDetailController(private val service: EventDetailService) { + @GetMapping("/{id}") + fun getEvent( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = ApiResponse.ok(service.getEvent(id, member)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt new file mode 100644 index 00000000..bc76a596 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.v2.api.event.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.event.EventItem +import kr.co.vividnext.sodalive.event.EventRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.AuthRepository +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class EventDetailService( + private val repository: EventRepository, + private val authRepository: AuthRepository, + private val memberContentPreferenceService: MemberContentPreferenceService, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional(readOnly = true) + fun getEvent(id: Long, member: Member?): EventItem { + if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") + + val event = repository.findByIdOrNull(id) + ?: throw SodaException(messageKey = "common.error.invalid_request") + val now = LocalDateTime.now() + val lang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO + + if (!event.isActive || event.startDate > now || event.endDate < now || event.lang != lang) { + throw SodaException(messageKey = "common.error.invalid_request") + } + + if (member?.role != MemberRole.ADMIN) { + val isAdult = member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false + if (event.isAdult != null && event.isAdult != isAdult) { + throw SodaException(messageKey = "common.error.invalid_request") + } + } + + return EventItem( + id = id, + thumbnailImageUrl = imageUrl(event.thumbnailImage), + detailImageUrl = event.detailImage?.let { imageUrl(it) }, + popupImageUrl = event.popupImage?.let { imageUrl(it) }, + link = event.link + ) + } + + private fun imageUrl(image: String): String = if (image.startsWith("https://")) image else "$cloudFrontHost/$image" +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt new file mode 100644 index 00000000..11f4cc36 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt @@ -0,0 +1,350 @@ +package kr.co.vividnext.sodalive.v2.api.event + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.event.Event +import kr.co.vividnext.sodalive.event.EventRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth +import kr.co.vividnext.sodalive.member.auth.AuthRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +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.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.mock.mockito.MockBean +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import java.time.LocalDateTime + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "cloud.aws.s3.bucket=test-bucket", + "spring.datasource.url=jdbc:h2:mem:event-detail-controller-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class EventDetailControllerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val mockMvc: MockMvc, + private val eventRepository: EventRepository, + private val memberRepository: MemberRepository, + private val authRepository: AuthRepository, + private val objectMapper: ObjectMapper +) { + @MockBean + private lateinit var s3Uploader: S3Uploader + + @BeforeEach + @AfterEach + fun cleanUp() { + eventRepository.deleteAllInBatch() + authRepository.deleteAllInBatch() + memberRepository.deleteAllInBatch() + } + + @Test + @DisplayName("익명 상세는 기존 목록과 같은 5개 필드와 외부 링크를 반환하고 엔티티를 변경하지 않는다") + fun shouldReturnAnonymousEventDetailWithLegacyFieldsWithoutMutatingTheEntity() { + val event = saveEvent( + activeEvent().apply { + detailImage = null + popupImage = null + link = "https://external.test/promotion?from=soda&target=event#detail" + } + ) + + val detail = httpGet("/api/v2/events/${event.id}") + + assertEquals( + objectMapper.readTree( + """{"success":true,"message":null,"data":{ + "id":${event.id},"thumbnailImageUrl":"https://cdn.test/thumbnail.png", + "detailImageUrl":null,"popupImageUrl":null, + "link":"https://external.test/promotion?from=soda&target=event#detail" + },"errorProperty":null}""" + ), + detail + ) + assertEquals(httpGet("/event")["data"]["eventList"].single(), detail["data"]) + val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow() + assertEquals("thumbnail.png", persisted.thumbnailImage) + assertEquals(null, persisted.detailImage) + assertEquals(null, persisted.popupImage) + assertEquals(event.link, persisted.link) + } + + @ParameterizedTest + @CsvSource( + "https://images.test/image.png, https://images.test/image.png", + "relative/image.png, https://cdn.test/relative/image.png", + "http://images.test/image.png, https://cdn.test/http://images.test/image.png" + ) + @DisplayName("세 이미지 필드는 HTTPS만 보존하고 상대 경로와 HTTP는 기존 CDN 규칙을 따른다") + fun shouldMapAllImageFieldsLikeTheLegacyList(image: String, expectedImage: String) { + val event = saveEvent( + activeEvent().apply { + thumbnailImage = image + detailImage = image + popupImage = image + link = null + } + ) + + val detail = httpGet("/api/v2/events/${event.id}")["data"] + + assertEquals(5, detail.size()) + assertEquals(event.id, detail["id"].asLong()) + assertEquals(expectedImage, detail["thumbnailImageUrl"].asText()) + assertEquals(expectedImage, detail["detailImageUrl"].asText()) + assertEquals(expectedImage, detail["popupImageUrl"].asText()) + assertEquals(true, detail["link"].isNull) + assertEquals(httpGet("/event")["data"]["eventList"].single(), detail) + val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow() + assertEquals(image, persisted.thumbnailImage) + assertEquals(image, persisted.detailImage) + assertEquals(image, persisted.popupImage) + } + + @ParameterizedTest + @CsvSource( + "DEFAULT, KO", + "KR, KO", + "JP, JA", + "US, KO", + nullValues = ["DEFAULT"] + ) + @DisplayName("익명 상세의 국가별 언어와 비노출 응답은 기존 목록과 일치한다") + fun shouldApplyTheSameCountryLanguageAsTheLegacyList(country: String?, expectedLang: Lang) { + val events = listOf(Lang.KO, Lang.JA, Lang.EN).map { lang -> + saveEvent(activeEvent().apply { this.lang = lang }) + } + val expected = events.single { it.lang == expectedLang } + val legacyItems = httpGet("/event", country)["data"]["eventList"] + assertEquals(listOf(expected.id), legacyItems.map { it["id"].asLong() }) + + events.forEach { event -> + val detail = httpGet("/api/v2/events/${event.id}", country) + if (event.id == expected.id) { + assertEquals(true, detail["success"].asBoolean()) + assertEquals(legacyItems.single(), detail["data"]) + } else { + assertInvalidRequest(detail) + } + } + } + + @Test + @DisplayName("성인 null false true의 익명 미인증 인증 관리자 노출은 KO와 JA 모두 기존 목록과 같다") + fun shouldMatchTheLegacyAdultMatrixForAnonymousUnverifiedVerifiedAndAdminViewers() { + val unverified = saveMember("unverified") + val verified = saveMember("verified", verified = true) + val admin = saveMember("admin", role = MemberRole.ADMIN) + assertEquals(null, authRepository.getAuthIdByMemberId(requireNotNull(unverified.id))) + assertEquals(1L, authRepository.count()) + + listOf("KR" to Lang.KO, "JP" to Lang.JA).forEach { (country, lang) -> + val general = saveEvent(activeEvent().apply { this.lang = lang }) + val nonAdult = saveEvent( + activeEvent().apply { + this.lang = lang + isAdult = false + } + ) + val adult = saveEvent( + activeEvent().apply { + this.lang = lang + isAdult = true + } + ) + val events = listOf(general, nonAdult, adult) + val viewers = listOf( + null to setOf(general.id, nonAdult.id), + unverified to setOf(general.id, nonAdult.id), + verified to setOf(general.id, adult.id), + admin to setOf(general.id, nonAdult.id, adult.id) + ) + + viewers.forEach { (member, expectedIds) -> + val legacyItems = viewerGet("/event", member, country)["data"]["eventList"] + assertEquals(expectedIds, legacyItems.map { it["id"].asLong() }.toSet()) + events.forEach { event -> + val detail = viewerGet("/api/v2/events/${event.id}", member, country) + if (event.id in expectedIds) { + assertEquals(true, detail["success"].asBoolean()) + assertEquals(legacyItems.single { it["id"].asLong() == event.id }, detail["data"]) + } else { + assertInvalidRequest(detail) + } + } + } + } + } + + @Test + @DisplayName("관리자도 활성 기간 언어 조건을 우회하지 못하고 비노출 상세는 미존재와 같다") + fun shouldRejectAllHiddenStatesEvenForAdminWhileMatchingTheLegacyList() { + val visible = saveEvent(activeEvent()) + val hidden = listOf( + activeEvent().apply { isActive = false }, + activeEvent().apply { + startDate = LocalDateTime.now().plusDays(1) + endDate = startDate.plusDays(1) + }, + activeEvent().apply { + endDate = LocalDateTime.now().minusDays(1) + startDate = endDate.minusDays(1) + }, + activeEvent().apply { lang = Lang.JA }, + activeEvent().apply { lang = Lang.EN } + ).map { saveEvent(it) } + val unverified = saveMember("hidden-unverified") + val verified = saveMember("hidden-verified", verified = true) + val admin = saveMember("hidden-admin", role = MemberRole.ADMIN) + val missing = httpGet("/api/v2/events/${Long.MAX_VALUE}") + assertInvalidRequest(missing) + + listOf(null, unverified, verified, admin).forEach { member -> + val legacyItems = viewerGet("/event", member)["data"]["eventList"] + assertEquals(listOf(visible.id), legacyItems.map { it["id"].asLong() }) + hidden.forEach { event -> + assertEquals(missing, viewerGet("/api/v2/events/${event.id}", member)) + } + } + } + + @ParameterizedTest + @CsvSource("0", "-1", "9223372036854775807") + @DisplayName("양수가 아니거나 미존재인 ID는 HTTP 200 invalid_request 실패 응답을 반환한다") + fun shouldRejectNonpositiveAndMissingIdsWithTheCommonInvalidRequest(id: String) { + assertInvalidRequest(httpGet("/api/v2/events/$id")) + val result = mockGet("/api/v2/events/$id") + assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey) + } + + @ParameterizedTest + @CsvSource("not-a-number", "9223372036854775808") + @DisplayName("숫자가 아니거나 Long 범위를 넘는 ID는 기존 바인딩과 공통 예외 응답을 유지한다") + fun shouldKeepTheGlobalBindingErrorResponseForMalformedIds(id: String) { + val response = httpGet("/api/v2/events/$id") + assertEquals( + objectMapper.readTree( + """{"success":false,"message":"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "data":null,"errorProperty":null}""" + ), + response + ) + assertEquals( + MethodArgumentTypeMismatchException::class.java, + mockGet("/api/v2/events/$id").resolvedException?.javaClass + ) + } + + @ParameterizedTest + @CsvSource( + "POST, /api/v2/events/1", "PUT, /api/v2/events/1", "DELETE, /api/v2/events/1", + "GET, /api/v2/events", "POST, /api/v2/events", "GET, /api/v2/events/1/extra", + "GET, /api/v2/events/1/extra/deeper", "GET, /event/popup", "GET, /admin/event/banner", + "POST, /event", "PUT, /event", "DELETE, /event/1", "GET, /admin/event/banner/1" + ) + @DisplayName("익명 쓰기 collection 하위 경로 기존 팝업 관리자 API는 계속 인증이 필요하다") + fun shouldKeepAnonymousRequestsOutsideTheDetailGetProtected(method: HttpMethod, path: String) { + val response = restTemplate.exchange(path, method, HttpEntity.EMPTY, String::class.java) + println("HTTP $method $path -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + } + + private fun httpGet(path: String, country: String? = null): JsonNode { + val headers = HttpHeaders().apply { + country?.let { set("CloudFront-Viewer-Country", it) } + } + val response = restTemplate.exchange(path, HttpMethod.GET, HttpEntity(headers), String::class.java) + println("HTTP GET $path country=$country -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode) + return objectMapper.readTree(response.body) + } + + private fun viewerGet(path: String, member: Member?, country: String? = null): JsonNode { + if (member == null) return httpGet(path, country) + return objectMapper.readTree(mockGet(path, member, country).response.contentAsByteArray) + } + + private fun mockGet(path: String, member: Member? = null, country: String? = null): MvcResult { + val request = get(path).apply { + country?.let { header("CloudFront-Viewer-Country", it) } + member?.let { + val adapter = MemberAdapter(it) + with(authentication(UsernamePasswordAuthenticationToken(adapter, null, adapter.authorities))) + } + } + val result = mockMvc.perform(request).andReturn() + println( + "MockMvc GET $path role=${member?.role} country=$country -> ${result.response.status} " + + result.response.getContentAsString(Charsets.UTF_8) + ) + assertEquals(200, result.response.status) + return result + } + + private fun assertInvalidRequest(response: JsonNode) { + assertEquals( + objectMapper.readTree( + """{"success":false,"message":"잘못된 요청입니다.","data":null,"errorProperty":null}""" + ), + response + ) + } + + private fun saveMember(seed: String, role: MemberRole = MemberRole.USER, verified: Boolean = false): Member { + val member = memberRepository.saveAndFlush(Member(password = "test-password", nickname = seed, role = role)) + if (verified) { + val auth = authRepository.saveAndFlush( + Auth("test-member", "19900101", "test-ci-$seed", "test-di-$seed", 1).apply { this.member = member } + ) + assertEquals(auth.id, authRepository.getAuthIdByMemberId(requireNotNull(member.id))) + } + return member + } + + private fun saveEvent(event: Event): Event = eventRepository.saveAndFlush(event) + + private fun activeEvent(): Event = Event( + thumbnailImage = "thumbnail.png", + detailImage = "detail.png", + popupImage = "popup.png", + link = "https://external.test/event", + title = "not-returned-title", + isAdult = null, + isPopup = false, + startDate = LocalDateTime.now().minusDays(1), + endDate = LocalDateTime.now().plusDays(1) + ) +}