diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt new file mode 100644 index 00000000..972be30b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt @@ -0,0 +1,59 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.dto.AdminSubBannerResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +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.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.MultipartFile +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/admin/sub-banners") +@PreAuthorize("hasRole('ADMIN')") +class AdminSubBannerController( + private val service: SubBannerService, + @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String +) { + @GetMapping + fun list(@RequestParam(required = false) placement: String?): ApiResponse> { + val selected = SubBannerPlacement.values().firstOrNull { it.name == placement } + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner placement") + return ApiResponse.ok(service.findAllByPlacement(selected).map { AdminSubBannerResponse.from(it, cloudFrontHost) }) + } + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun create( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") request: String + ): ApiResponse = ApiResponse.ok( + AdminSubBannerResponse.from(service.create(image, request), cloudFrontHost) + ) + + @PutMapping("/{id}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun update( + @PathVariable id: Long, + @RequestPart("image", required = false) image: MultipartFile?, + @RequestPart("request") request: String + ): ApiResponse = ApiResponse.ok( + AdminSubBannerResponse.from(service.update(id, image, request), cloudFrontHost) + ) + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): ApiResponse { + service.delete(id) + return ApiResponse.ok() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt new file mode 100644 index 00000000..7d77e617 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt @@ -0,0 +1,191 @@ +package kr.co.vividnext.sodalive.v2.subbanner.application + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +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.utils.generateFileName +import kr.co.vividnext.sodalive.utils.validateImage +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerRequest +import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException +import java.net.URI +import java.net.URISyntaxException +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.time.format.ResolverStyle +import java.time.temporal.ChronoUnit + +@Service +class SubBannerService( + private val repository: SubBannerRepository, + private val s3Uploader: S3Uploader, + private val objectMapper: ObjectMapper, + @Value("\${cloud.aws.s3.bucket}") private val bucket: String, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } +) { + @Transactional(readOnly = true) + fun findAllByPlacement(placement: SubBannerPlacement): List = repository.findAllByPlacement(placement) + + @Transactional(readOnly = true) + fun findVisibleByPlacement(placement: SubBannerPlacement): List { + val now = nowProvider().truncatedTo(ChronoUnit.SECONDS) + return repository.findVisibleByPlacement(placement, now) + } + + @Transactional + fun create(image: MultipartFile, request: String): SubBanner { + val fields = readFields(request) + val input = readRequest(fields) + val startDate = parseDate(input.startDate, 0) + val endDate = parseDate(input.endDate, 59) + validateRange(startDate, endDate) + val imagePath = uploadImage(image) + return repository.save( + SubBanner( + placement = input.placement, + imagePath = imagePath, + webUrl = input.webUrl, + androidDeepLink = input.androidDeepLink, + iosDeepLink = input.iosDeepLink, + startDate = startDate, + endDate = endDate + ) + ) + } + + @Transactional + fun update(id: Long, image: MultipartFile?, request: String): SubBanner { + val existing = findExisting(id) + val fields = readFields(request) + val input = readRequest(fields) + val startDate = if (fields.has("startDate")) parseDate(input.startDate, 0) else existing.startDate + val endDate = if (fields.has("endDate")) parseDate(input.endDate, 59) else existing.endDate + validateRange(startDate, endDate) + val imagePath = image?.let { uploadImage(it) } ?: existing.imagePath + return repository.save( + existing.copy( + placement = input.placement, + imagePath = imagePath, + webUrl = input.webUrl, + androidDeepLink = input.androidDeepLink, + iosDeepLink = input.iosDeepLink, + startDate = startDate, + endDate = endDate + ) + ) + } + + @Transactional + fun delete(id: Long) { + findExisting(id) + repository.deleteById(id) + } + + private fun findExisting(id: Long): SubBanner = repository.findById(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Sub-banner not found") + + private fun readFields(request: String): JsonNode { + val fields = try { + objectMapper.readerFor(JsonNode::class.java) + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readValue(request) + } catch (_: JsonProcessingException) { + invalidRequest() + } + if (!fields.isObject || !fields.path("placement").isTextual || + SubBannerPlacement.values().none { it.name == fields["placement"].textValue() } + ) { + invalidRequest() + } + listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field -> + val value = fields[field] + if (value == null || (!value.isNull && !value.isTextual)) invalidRequest() + } + listOf("startDate", "endDate").forEach { field -> + val value = fields[field] + if (value != null && !value.isNull && !value.isTextual) invalidRequest() + } + return fields + } + + private fun readRequest(fields: JsonNode): SubBannerRequest { + val input = objectMapper.treeToValue(fields, SubBannerRequest::class.java) + val normalized = input.copy( + webUrl = input.webUrl?.trim()?.takeIf { it.isNotEmpty() }, + androidDeepLink = input.androidDeepLink?.trim()?.takeIf { it.isNotEmpty() }, + iosDeepLink = input.iosDeepLink?.trim()?.takeIf { it.isNotEmpty() } + ) + if (listOf(normalized.webUrl, normalized.androidDeepLink, normalized.iosDeepLink).all { it == null }) invalidRequest() + validateUri(normalized.webUrl, web = true) + validateUri(normalized.androidDeepLink, web = false) + validateUri(normalized.iosDeepLink, web = false) + return normalized + } + + private fun validateUri(value: String?, web: Boolean) { + if (value == null) return + val uri = try { + URI(value) + } catch (_: URISyntaxException) { + invalidRequest() + } + if (!uri.isAbsolute || listOf("javascript", "data", "file", "content").any { it.equals(uri.scheme, ignoreCase = true) }) { + invalidRequest() + } + if (web && ((!uri.scheme.equals("http", true) && !uri.scheme.equals("https", true)) || uri.host.isNullOrBlank())) { + invalidRequest() + } + } + + private fun parseDate(value: String?, second: Int): LocalDateTime? { + if (value == null) return null + if (value.length != 16) invalidRequest() + val utc = try { + val local = LocalDateTime.parse(value, DATE_FORMAT).withSecond(second) + val zone = ZoneId.of("Asia/Seoul") + if (zone.rules.getValidOffsets(local).isEmpty()) invalidRequest() + local.atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + } catch (_: DateTimeParseException) { + invalidRequest() + } + if (utc < MIN_TIMESTAMP || utc > MAX_TIMESTAMP) invalidRequest() + return utc + } + + private fun validateRange(startDate: LocalDateTime?, endDate: LocalDateTime?) { + if (startDate != null && endDate != null && startDate > endDate) invalidRequest() + } + + private fun uploadImage(image: MultipartFile): String { + if (image.isEmpty) invalidRequest() + try { + validateImage(image, gifAllowed = true) + } catch (_: SodaException) { + invalidRequest() + } + return image.inputStream.use { s3Uploader.upload(it, bucket, "sub_banner/${generateFileName()}") } + } + + private fun invalidRequest(): Nothing = throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner request") + + companion object { + private val DATE_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm").withResolverStyle(ResolverStyle.STRICT) + private val MIN_TIMESTAMP = LocalDateTime.of(1970, 1, 1, 0, 0, 1) + private val MAX_TIMESTAMP = LocalDateTime.of(2038, 1, 19, 3, 14, 7) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt new file mode 100644 index 00000000..4febc4b5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.v2.subbanner.dto + +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +data class AdminSubBannerResponse( + val id: Long, + val placement: SubBannerPlacement, + val imageUrl: String, + val webUrl: String?, + val androidDeepLink: String?, + val iosDeepLink: String?, + val startDate: String?, + val endDate: String? +) { + companion object { + fun from(banner: SubBanner, cloudFrontHost: String): AdminSubBannerResponse = AdminSubBannerResponse( + id = requireNotNull(banner.id), + placement = banner.placement, + imageUrl = "$cloudFrontHost/${banner.imagePath}", + webUrl = banner.webUrl, + androidDeepLink = banner.androidDeepLink, + iosDeepLink = banner.iosDeepLink, + startDate = formatDate(banner.startDate), + endDate = formatDate(banner.endDate) + ) + + private fun formatDate(value: LocalDateTime?): String? = value?.atZone(ZoneOffset.UTC) + ?.withZoneSameInstant(ZoneId.of("Asia/Seoul")) + ?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt new file mode 100644 index 00000000..a87ad083 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.v2.subbanner.dto + +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement + +data class SubBannerRequest( + val placement: SubBannerPlacement, + val webUrl: String?, + val androidDeepLink: String?, + val iosDeepLink: String?, + val startDate: String? = null, + val endDate: String? = null +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt new file mode 100644 index 00000000..0ab3142e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt @@ -0,0 +1,596 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web + +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.jwt.TokenProvider +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 kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +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.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +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.boot.test.web.server.LocalServerPort +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.util.LinkedMultiValueMap +import java.io.InputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Base64 +import java.util.UUID +import javax.persistence.EntityManager + +@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:admin-sub-banner-http;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AdminSubBannerControllerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val objectMapper: ObjectMapper, + private val repository: SubBannerRepository, + private val jdbcTemplate: JdbcTemplate, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager, + private val memberRepository: MemberRepository, + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository +) { + @LocalServerPort + private var port: Int = 0 + + @MockBean + private lateinit var s3Uploader: S3Uploader + + private lateinit var adminToken: String + private val memberIds = mutableListOf() + + @BeforeEach + fun setUp() { + jdbcTemplate.update("delete from sub_banner") + jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN start_date TIMESTAMP(0)") + jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN end_date TIMESTAMP(0)") + adminToken = tokenFor(MemberRole.ADMIN) + Mockito.`when`( + s3Uploader.upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull()) + ).thenAnswer { invocation -> + assertEquals("test-bucket", invocation.getArgument(1)) + assertTrue(invocation.getArgument(2).matches(Regex("sub_banner/[A-Za-z0-9_=-]+"))) + assertEquals(PNG.toList(), invocation.getArgument(0).readBytes().toList()) + invocation.getArgument(2) + } + } + + @AfterEach + fun cleanUp() { + jdbcTemplate.update("delete from sub_banner") + tokenRepository.deleteAll() + memberRepository.deleteAllById(memberIds) + memberIds.clear() + } + + @Test + @DisplayName("실제 JWT HTTP CRUD는 관리자 8필드·삭제 null을 유지하며 소프트 삭제 후 수정·재삭제를 거부한다") + fun shouldCreateListUpdateAndSoftDeleteWithExactAdminResponse() { + val createdResponse = send(HttpMethod.POST) + evidence("POST", createdResponse) + val created = success(createdResponse) + assertAdminFields(created) + val id = created["id"].asLong() + val stored = reload(id) + assertEquals("https://cdn.test/${stored.imagePath}", created["imageUrl"].asText()) + assertEquals(WEB, stored.webUrl) + assertNull(stored.startDate) + assertNull(stored.endDate) + assertEquals(listOf(created), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList()) + + val updatedResponse = send( + HttpMethod.PUT, + "/$id", + baseFields() + mapOf("placement" to "CONTENT_RECOMMENDATION", "webUrl" to null, "iosDeepLink" to "custom:promo") + + dates("2026-10-28 00:00", "2026-10-29 23:59") + ) + evidence("PUT", updatedResponse) + val updated = success(updatedResponse) + assertAdminFields(updated) + assertEquals(id, updated["id"].asLong()) + assertTrue(updated["webUrl"].isNull) + assertEquals("custom:promo", updated["iosDeepLink"].asText()) + assertNotEquals(stored.imagePath, reload(id).imagePath) + assertEquals(emptyList(), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList()) + assertEquals(listOf(updated), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList()) + + val beforeDelete = storedRows().single() + Mockito.clearInvocations(s3Uploader) + val deleted = send(HttpMethod.DELETE, "/$id") + evidence("DELETE", deleted) + assertTrue(success(deleted, allowNullData = true).isNull) + assertNull(transactionTemplate.execute { repository.findById(id) }) + assertEquals(1L, rowCount(), "Soft delete must retain the original row") + val retained = storedRows().single() + assertEquals(true, beforeDelete["is_active"]) + assertEquals(false, retained["is_active"]) + assertEquals(beforeDelete - setOf("is_active", "updated_at"), retained - setOf("is_active", "updated_at")) + assertNotEquals(beforeDelete["updated_at"], retained["updated_at"]) + println("DB EVIDENCE soft-delete before=$beforeDelete after=$retained") + assertEquals(emptyList(), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList()) + Mockito.verifyNoInteractions(s3Uploader) + listOf( + send(HttpMethod.DELETE, "/$id"), + send(HttpMethod.PUT, "/$id", baseFields() + ("isActive" to true)), + send(HttpMethod.PUT, "/$id", rawRequest = "{") + ).forEach { response -> + evidence("inactive mutation", response) + assertStatus(response, HttpStatus.NOT_FOUND) + assertEquals(listOf(retained), storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 3, 4, 5, 6, 7]) + @DisplayName("주소 8개 조합 중 모두 빈 조합만 거부하고 나머지는 생성·수정한다") + fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations(mask: Int) { + val fields = baseFields() + mapOf( + "webUrl" to if (mask and 1 != 0) " $WEB " else " \t ", + "androidDeepLink" to if (mask and 2 != 0) "sampleapp://promo?id=a%2Fb" else null, + "iosDeepLink" to if (mask and 4 != 0) "other-app:promo" else "" + ) + if (mask == 0) { + assertRejectedWithoutMutation(fields) + return + } + val created = success(send(HttpMethod.POST, fields = fields)) + val id = created["id"].asLong() + val stored = reload(id) + assertEquals(if (mask and 1 != 0) WEB else null, stored.webUrl) + assertEquals(if (mask and 2 != 0) "sampleapp://promo?id=a%2Fb" else null, stored.androidDeepLink) + assertEquals(if (mask and 4 != 0) "other-app:promo" else null, stored.iosDeepLink) + assertEquals(created, success(send(HttpMethod.PUT, "/$id", fields, image = null))) + } + + @Test + @DisplayName("raw JSON의 필수 기본 필드·실제 타입·객체·정확한 placement를 업로드 전에 검증한다") + fun shouldRejectMissingBaseFieldsAndNonStringLinksBeforeUpload() { + baseFields().keys.forEach { missing -> assertRejectedWithoutMutation(baseFields() - missing) } + listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field -> + listOf(1, true, emptyList(), emptyMap()).forEach { value -> + assertRejectedWithoutMutation(baseFields() + (field to value)) + } + } + listOf(null, 0, true, emptyList(), "", "home_recommendation", " HOME_RECOMMENDATION", "OTHER").forEach { + assertRejectedWithoutMutation(baseFields() + ("placement" to it)) + } + listOf("{", "[]", "null", "42", "\"string\"", "{}", "{} {}").forEach { raw -> + assertRejectedWithoutMutation(rawRequest = raw) + } + assertRejectedWithoutMutation(baseFields() + mapOf("webUrl" to null, "androidDeepLink" to null, "iosDeepLink" to null)) + } + + @Test + @DisplayName("위험 scheme·상대 URI·실제 host 없는 웹 주소와 문법 오류를 생성·수정 전에 거부한다") + fun shouldRejectUnsafeOrMalformedUrisBeforeUploadOrPersistence() { + evidence("invalid URI", send(HttpMethod.POST, fields = baseFields() + ("webUrl" to "javascript:alert(1)"))) + val unsafe = listOf("javascript:alert(1)", "JaVaScRiPt:alert(1)", "DATA:text/plain,x", "file:///tmp/a", "CoNtEnT://a") + listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field -> + (unsafe + listOf("relative/path", "//example.test/path", "app://bad path", "app://bad%zz")).forEach { value -> + assertRejectedWithoutMutation(baseFields() + (field to value)) + } + } + listOf("ftp://example.test", "https:/missing-host", "http:///path", "https://", "https://bad_host/a").forEach { + assertRejectedWithoutMutation(baseFields() + ("webUrl" to it)) + } + } + + @Test + @DisplayName("주소는 trim 외 대소문자·인코딩·순서·fragment·긴 문자열을 변경하지 않는다") + fun shouldPreserveLinkUriAfterTrimOnly() { + val links = mapOf( + "webUrl" to "HTTPS://Example.TEST/a%2fb/../x?b=2&a=%2F+%20#Frag", + "androidDeepLink" to "Intent://promo/Item?id=a%2Fb#Intent;scheme=sample;end", + "iosDeepLink" to "new-custom:Item?value=${"a".repeat(512)}%2f" + ) + val created = success(send(HttpMethod.POST, fields = baseFields() + links.mapValues { " \t${it.value}\n " })) + val stored = reload(created["id"].asLong()) + links.forEach { (field, value) -> assertEquals(value, created[field].asText()) } + assertEquals(links["webUrl"], stored.webUrl) + assertEquals(links["androidDeepLink"], stored.androidDeepLink) + assertEquals(links["iosDeepLink"], stored.iosDeepLink) + } + + @ParameterizedTest + @CsvSource( + "OMITTED, OMITTED, , ", + "NULL, NULL, , ", + "2026-10-27 00:00, OMITTED, 2026-10-26T15:00:00, ", + "OMITTED, 2026-10-28 23:59, , 2026-10-28T14:59:59", + "2026-10-27 00:00, 2026-10-28 23:59, 2026-10-26T15:00:00, 2026-10-28T14:59:59", + "2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27T14:59:00, 2026-10-27T14:59:59", + "1970-01-01 09:01, 2038-01-19 12:13, 1970-01-01T00:01:00, 2038-01-19T03:13:59", + "OMITTED, 1970-01-01 09:00, , 1970-01-01T00:00:59", + "2038-01-19 12:14, OMITTED, 2038-01-19T03:14:00, ", + "1988-05-08 01:30, 1988-05-08 03:30, 1988-05-07T16:30:00, 1988-05-07T17:30:59", + "1988-10-09 02:30, 1988-10-09 02:30, 1988-10-08T16:30:00, 1988-10-08T16:30:59" + ) + @DisplayName("H2 TIMESTAMP(0) 재조회 후 UTC 초 경계와 HTTP KST 분 문자열이 정확히 왕복한다") + fun shouldRoundTripAdminDateStringsExactlyAfterDatabaseReload( + start: String, + end: String, + utcStart: String?, + utcEnd: String? + ) { + val response = send(HttpMethod.POST, fields = baseFields() + dates(start, end)) + if (start == "2026-10-27 23:59") evidence("POST dates", response) + val created = success(response) + val stored = reload(created["id"].asLong()) + assertEquals(utcStart?.let(LocalDateTime::parse), stored.startDate) + assertEquals(utcEnd?.let(LocalDateTime::parse), stored.endDate) + assertDate(created, "startDate", start) + assertDate(created, "endDate", end) + assertEquals(listOf(created), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList()) + } + + @ParameterizedTest + @ValueSource(strings = ["startDate", "endDate"]) + @DisplayName("존재하지 않는 KST DST gap 분은 POST·PUT 모두 업로드와 DB 변경 전에 거부한다") + fun shouldRejectKstDstGapBeforeUploadOrPersistence(field: String) { + assertRejectedWithoutMutation( + baseFields() + mapOf("startDate" to null, "endDate" to null) + (field to "1988-05-08 02:30") + ) + } + + @Test + @DisplayName("날짜의 실제 JSON 타입·엄격한 형식·달력·DB TIMESTAMP 범위·역전을 업로드 전에 거부한다") + fun shouldRejectInvalidDateJsonTypesFormatsAndRangesBeforeUpload() { + val invalid = listOf( + 2026, true, emptyList(), emptyMap(), "", " ", "not-a-date", + "2026-02-29 10:00", "2026-04-31 10:00", "2026-13-01 10:00", "2026-10-27 24:00", + "2026-10-27T10:00", "2026-10-27 10:00:00", "2026-10-27 10:00Z", "2026-10-27 10:00+09:00", + "2026-1-01 10:00", "+02026-10-27 10:00", " 2026-10-27 10:00", "2026-10-27 10:00 ", + "0000-01-01 00:00", "9999-12-31 23:59", "1969-12-31 23:59", "2039-01-01 00:00" + ) + listOf("startDate", "endDate").forEach { field -> + invalid.forEach { value -> assertRejectedWithoutMutation(baseFields() + (field to value)) } + } + assertRejectedWithoutMutation(baseFields() + dates("1970-01-01 09:00", "OMITTED")) + assertRejectedWithoutMutation(baseFields() + dates("OMITTED", "2038-01-19 12:14")) + assertRejectedWithoutMutation(baseFields() + dates("2026-10-29 00:00", "2026-10-28 23:59")) + } + + @ParameterizedTest + @CsvSource( + "OMITTED, OMITTED, 2026-10-28 00:00, 2026-10-29 23:59", + "NULL, OMITTED, NULL, 2026-10-29 23:59", + "OMITTED, NULL, 2026-10-28 00:00, NULL", + "NULL, NULL, NULL, NULL", + "2026-10-27 00:00, OMITTED, 2026-10-27 00:00, 2026-10-29 23:59", + "OMITTED, 2026-10-30 23:59, 2026-10-28 00:00, 2026-10-30 23:59", + "2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59", + "NULL, 2026-10-26 00:00, NULL, 2026-10-26 00:00", + "2026-10-30 00:00, NULL, 2026-10-30 00:00, NULL" + ) + @DisplayName("수정 생략은 경계를 보존하고 null은 개별 해제하며 이미지·주소는 유지한다") + fun shouldUpdateDatesWithOmittedPreservedAndNullCleared(start: String, end: String, wantStart: String, wantEnd: String) { + val id = createScheduled() + val before = reload(id) + Mockito.clearInvocations(s3Uploader) + val updated = success(send(HttpMethod.PUT, "/$id", baseFields() + dates(start, end), image = null)) + assertDate(updated, "startDate", wantStart) + assertDate(updated, "endDate", wantEnd) + val stored = reload(id) + assertEquals(expectedUtc(wantStart, 0), stored.startDate) + assertEquals(expectedUtc(wantEnd, 59), stored.endDate) + assertEquals(before.imagePath, stored.imagePath) + assertEquals(before.webUrl, stored.webUrl) + Mockito.verifyNoInteractions(s3Uploader) + } + + @ParameterizedTest + @CsvSource("2026-10-30 00:00, OMITTED", "OMITTED, 2026-10-27 23:59", "2026-10-30 00:00, 2026-10-29 23:59") + @DisplayName("수정은 유지한 날짜와 새 날짜를 병합한 역전 범위를 업로드 전에 거부한다") + fun shouldRejectReversedEffectiveUpdateRange(start: String, end: String) { + val id = createScheduled() + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + assertStatus(send(HttpMethod.PUT, "/$id", baseFields() + dates(start, end)), HttpStatus.BAD_REQUEST) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("관리자 목록은 빈 배열·placement 격리·미래·만료·무기한과 20개 초과 id 순서를 보장한다") + fun shouldListAllPeriodsInIdOrderWithoutTruncation() { + assertEquals(emptyList(), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList()) + val ids = (0 until 23).map { index -> + val period = when (index % 3) { + 0 -> dates("2030-01-01 00:00", "2030-01-02 23:59") + 1 -> dates("2020-01-01 00:00", "2020-01-02 23:59") + else -> emptyMap() + } + success(send(HttpMethod.POST, fields = baseFields() + period))["id"].asLong() + } + val other = success(send(HttpMethod.POST, fields = baseFields() + ("placement" to "CONTENT_RECOMMENDATION"))) + assertEquals(ids, success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).map { it["id"].asLong() }) + assertEquals(listOf(other), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList()) + listOf("", "?placement=", "?placement=OTHER", "?placement=home_recommendation").forEach { + assertStatus(send(HttpMethod.GET, it), HttpStatus.BAD_REQUEST) + } + } + + @Test + @DisplayName("빈 파일·위장 이미지 교체를 거부하고 업로드 실패 시 기존 행과 이미지 경로를 보존한다") + fun shouldKeepExistingImageWhenReplacementUploadFails() { + val id = createScheduled() + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + listOf(byteArrayOf(), "not an image".toByteArray()).forEach { bytes -> + assertStatus(send(HttpMethod.POST, image = bytes), HttpStatus.BAD_REQUEST) + assertStatus(send(HttpMethod.PUT, "/$id", image = bytes), HttpStatus.BAD_REQUEST) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + Mockito.doThrow(IllegalStateException("Upload failed")).`when`(s3Uploader) + .upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull()) + val response = send(HttpMethod.PUT, "/$id", baseFields() + ("webUrl" to "https://changed.test")) + assertEquals(HttpStatus.OK, response.statusCode) + assertFalse(objectMapper.readTree(response.body)["success"].asBoolean()) + assertEquals(before, storedRows()) + assertFalse(requireNotNull(response.body).contains("Upload failed")) + } + + @Test + @DisplayName("없는 id의 수정·삭제는 404이며 이미지 업로드나 행 변경이 없다") + fun shouldRejectMissingIdWithoutMutation() { + createScheduled() + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + listOf(HttpMethod.PUT, HttpMethod.DELETE).forEach { method -> + val response = send(method, "/${Long.MAX_VALUE}") + evidence("missing $method", response) + assertStatus(response, HttpStatus.NOT_FOUND) + } + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("익명 401과 모든 비관리자 역할의 CRUD 거부는 업로드·DB 변경 없이 기존 정책을 따른다") + fun shouldRejectAnonymousAndNonAdminBeforeUpload() { + val id = createScheduled() + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + (listOf(null) + MemberRole.values().filter { it != MemberRole.ADMIN }.map { tokenFor(it) }).forEach { token -> + listOf( + HttpMethod.GET to "?placement=HOME_RECOMMENDATION", + HttpMethod.POST to "", + HttpMethod.PUT to "/$id", + HttpMethod.DELETE to "/$id" + ).forEach { (method, suffix) -> + val response = send(method, suffix, token = token) + if (token == null) { + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + } else { + assertEquals(HttpStatus.OK, response.statusCode) + val body = objectMapper.readTree(response.body) + assertFalse(body["success"].asBoolean()) + assertEquals("You do not have permission.", body["message"].asText()) + } + if (method == HttpMethod.DELETE) evidence(if (token == null) "anonymous" else "non-admin", response) + } + } + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + + @Test + @DisplayName("필수 multipart part 누락·잘못된 multipart는 기존 framework 오류 envelope를 반환하며 변경하지 않는다") + fun shouldCharacterizeMissingImageAndMalformedMultipart() { + val responses = listOf( + send(HttpMethod.POST, image = null), + send(HttpMethod.POST, includeRequest = false), + restTemplate.exchange( + url(), + HttpMethod.POST, + HttpEntity("not a multipart body", headers(adminToken).apply { contentType = MediaType.MULTIPART_FORM_DATA }), + String::class.java + ) + ) + responses.forEach { response -> + evidence("framework multipart", response) + assertEquals(HttpStatus.OK, response.statusCode) + val body = objectMapper.readTree(response.body) + assertFalse(body["success"].asBoolean()) + assertTrue(body["data"].isNull) + } + assertEquals(0L, rowCount()) + Mockito.verifyNoInteractions(s3Uploader) + } + + private fun assertRejectedWithoutMutation(fields: Map = baseFields(), rawRequest: String? = null) { + if (rowCount() == 0L) createScheduled() + val id = jdbcTemplate.queryForObject("select min(id) from sub_banner", Long::class.java) + val before = storedRows() + Mockito.clearInvocations(s3Uploader) + listOf(HttpMethod.POST to "", HttpMethod.PUT to "/$id").forEach { (method, suffix) -> + val response = send(method, suffix, fields, rawRequest = rawRequest) + assertStatus(response, HttpStatus.BAD_REQUEST) + assertFalse(requireNotNull(response.body).contains("javascript", ignoreCase = true)) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + } + } + + private fun createScheduled(): Long = success( + send(HttpMethod.POST, fields = baseFields() + dates("2026-10-28 00:00", "2026-10-29 23:59")) + )["id"].asLong() + + private fun send( + method: HttpMethod, + suffix: String = "", + fields: Map = baseFields(), + image: ByteArray? = PNG, + token: String? = adminToken, + rawRequest: String? = null, + includeRequest: Boolean = true + ): ResponseEntity { + val headers = headers(token) + val body = if (method == HttpMethod.POST || method == HttpMethod.PUT) { + headers.contentType = MediaType.MULTIPART_FORM_DATA + LinkedMultiValueMap().apply { + if (includeRequest) { + add( + "request", + HttpEntity( + rawRequest ?: objectMapper.writeValueAsString(fields), + HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON } + ) + ) + } + if (image != null) { + add( + "image", + HttpEntity( + object : ByteArrayResource(image) { + override fun getFilename(): String = "client-name.png" + }, + HttpHeaders().apply { contentType = MediaType.IMAGE_PNG } + ) + ) + } + } + } else { + null + } + return restTemplate.exchange(url() + suffix, method, HttpEntity(body, headers), String::class.java) + } + + private fun headers(token: String?): HttpHeaders = HttpHeaders().apply { + token?.let { setBearerAuth(it) } + set(HttpHeaders.ACCEPT_LANGUAGE, "en") + accept = listOf(MediaType.APPLICATION_JSON) + } + + private fun success(response: ResponseEntity, allowNullData: Boolean = false): JsonNode { + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet()) + assertTrue(body["success"].asBoolean(), response.body) + assertTrue(body["message"].isNull) + assertTrue(body["errorProperty"].isNull) + if (!allowNullData) assertFalse(body["data"].isNull, "Successful create/update/list must return data") + return body["data"] + } + + private fun assertAdminFields(data: JsonNode) { + assertEquals( + setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"), + data.fieldNames().asSequence().toSet() + ) + } + + private fun assertStatus(response: ResponseEntity, status: HttpStatus) { + assertEquals(status, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(status.value(), body["status"].asInt()) + assertFalse(body.has("trace")) + assertFalse(body.has("exception")) + } + + private fun reload(id: Long): SubBanner = requireNotNull( + transactionTemplate.execute { + entityManager.flush() + entityManager.clear() + repository.findById(id) + } + ) + + private fun storedRows(): List> = jdbcTemplate.queryForList("select * from sub_banner order by id") + + private fun rowCount(): Long = requireNotNull( + jdbcTemplate.queryForObject("select count(*) from sub_banner", Long::class.java) + ) + + private fun tokenFor(role: MemberRole): String { + val member = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role) + ) + val id = requireNotNull(member.id) + memberIds.add(id) + return tokenProvider.createToken( + UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))), + id + ) + } + + private fun assertDate(data: JsonNode, field: String, value: String) { + if (value == "NULL" || value == "OMITTED") assertTrue(data[field].isNull) else assertEquals(value, data[field].asText()) + } + + private fun dates(start: String, end: String): Map = mapOf("startDate" to start, "endDate" to end) + .filterValues { it != "OMITTED" } + .mapValues { if (it.value == "NULL") null else it.value } + + private fun expectedUtc(value: String, second: Int): LocalDateTime? = if (value == "NULL") { + null + } else { + LocalDateTime.parse(value, DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm")).minusHours(9).withSecond(second) + } + + private fun evidence(label: String, response: ResponseEntity) { + println("HTTP EVIDENCE $label ${response.statusCodeValue}: ${response.body}") + } + + private fun url(): String = "http://127.0.0.1:$port/admin/sub-banners" + + private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream() + + private fun baseFields(): Map = mapOf( + "placement" to "HOME_RECOMMENDATION", + "webUrl" to WEB, + "androidDeepLink" to null, + "iosDeepLink" to null + ) + + companion object { + private const val WEB = "https://example.test/promo?x=a%2Fb" + private val PNG = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a0foAAAAASUVORK5CYII=" + ) + } +}