From ad46fd608ee87433da52137bad5249ceb71c219b Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 24 May 2026 13:52:14 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Refactor:=20OAuth=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/auth/OAuth2UserService.kt | 71 ++++++++----------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt b/src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt index 23ca1c3..ac056e2 100644 --- a/src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt +++ b/src/main/kotlin/com/tripsync/application/auth/OAuth2UserService.kt @@ -4,12 +4,9 @@ import com.tripsync.common.dto.ApiResponse import com.tripsync.common.exception.DomainException import com.tripsync.domain.entity.User import com.tripsync.domain.enums.AuthProvider -import com.tripsync.domain.enums.YnFlag import com.tripsync.domain.repository.UserRepository -import mu.KotlinLogging import org.springframework.http.HttpStatus import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest import org.springframework.security.oauth2.core.user.OAuth2User import org.springframework.stereotype.Service @@ -18,8 +15,6 @@ class OAuth2UserService( private val userRepository: UserRepository, private val jwtTokenProvider: JwtTokenProvider, ) : DefaultOAuth2UserService() { - private val logger = KotlinLogging.logger {} - fun processGoogleLogin(oAuth2User: OAuth2User): ApiResponse> { val email = oAuth2User.getAttribute("email") ?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_EMAIL_MISSING", "이메일 정보를 가져올 수 없습니다.") @@ -28,33 +23,14 @@ class OAuth2UserService( val providerId = oAuth2User.getAttribute("sub") ?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_ID_MISSING", "사용자 ID를 가져올 수 없습니다.") - val existingUser = userRepository.findByAuthProviderAndProviderUserId(AuthProvider.GOOGLE, providerId) - - val user = if (existingUser != null) { - existingUser - } else { - userRepository.save( - User( - nickname = name, - email = email, - authProvider = AuthProvider.GOOGLE, - providerUserId = providerId, - profileImageUrl = picture, - ) - ) - } - - val token = jwtTokenProvider.generateToken(user.id, user.isGuest) - @Suppress("UNCHECKED_CAST") - return ApiResponse.ok( - mapOf( - "token" to token, - "userId" to user.id, - "nickname" to user.nickname, - "email" to (user.email ?: ""), - "authProvider" to user.authProvider.name, - ) as Map + val user = findOrCreateOAuthUser( + provider = AuthProvider.GOOGLE, + providerId = providerId, + nickname = name, + email = email, + profileImageUrl = picture, ) + return authResponse(user) } fun processKakaoLogin(oAuth2User: OAuth2User): ApiResponse> { @@ -68,24 +44,37 @@ class OAuth2UserService( val providerId = attributes["id"]?.toString() ?: throw DomainException(HttpStatus.BAD_REQUEST, "OAUTH_ID_MISSING", "사용자 ID를 가져올 수 없습니다.") - val existingUser = userRepository.findByAuthProviderAndProviderUserId(AuthProvider.KAKAO, providerId) + val user = findOrCreateOAuthUser( + provider = AuthProvider.KAKAO, + providerId = providerId, + nickname = nickname, + email = email, + profileImageUrl = profileImage, + ) + return authResponse(user) + } - val user = if (existingUser != null) { - existingUser - } else { - userRepository.save( + private fun findOrCreateOAuthUser( + provider: AuthProvider, + providerId: String, + nickname: String, + email: String?, + profileImageUrl: String?, + ): User { + return userRepository.findByAuthProviderAndProviderUserId(provider, providerId) + ?: userRepository.save( User( nickname = nickname, email = email, - authProvider = AuthProvider.KAKAO, + authProvider = provider, providerUserId = providerId, - profileImageUrl = profileImage, + profileImageUrl = profileImageUrl, ) ) - } + } + private fun authResponse(user: User): ApiResponse> { val token = jwtTokenProvider.generateToken(user.id, user.isGuest) - @Suppress("UNCHECKED_CAST") return ApiResponse.ok( mapOf( "token" to token, @@ -93,7 +82,7 @@ class OAuth2UserService( "nickname" to user.nickname, "email" to (user.email ?: ""), "authProvider" to user.authProvider.name, - ) as Map + ) ) } } From 07c9f31d905ed67b6a39b3c57e99757d8c8a08b3 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Sun, 24 May 2026 13:52:14 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Refactor:=20=EC=9D=BC=EC=A0=95=20LLM=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=BA=90?= =?UTF-8?q?=EC=8A=A4=ED=8C=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tripsync/application/schedule/ScheduleResponseMapper.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt index 85df105..c3e62d2 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt @@ -139,9 +139,8 @@ class ScheduleResponseMapper( return formatStoredSchedule(schedule).filterKeys { it in publicKeys } } - @Suppress("UNCHECKED_CAST") private fun formatLlmMetadata(schedule: Schedule): Map { - val metadata = schedule.generationInput["llm"] as? Map ?: emptyMap() + val metadata = schedule.generationInput["llm"] as? Map<*, *> ?: emptyMap() val provider = schedule.llmProvider ?: metadata["provider"] return mapOf( "provider" to provider, From ee9c5a4988c9a286d7bcdfbe52c7eab935b28c82 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Fri, 29 May 2026 16:35:54 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=9D=91=EB=8B=B5=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/tripsync/web/auth/AuthController.kt | 7 ++-- .../kotlin/com/tripsync/AuthContractTests.kt | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/tripsync/web/auth/AuthController.kt b/src/main/kotlin/com/tripsync/web/auth/AuthController.kt index 19ac037..25825b8 100644 --- a/src/main/kotlin/com/tripsync/web/auth/AuthController.kt +++ b/src/main/kotlin/com/tripsync/web/auth/AuthController.kt @@ -5,6 +5,7 @@ import com.tripsync.application.auth.JwtAuthenticationFilter import com.tripsync.application.auth.JwtTokenProvider import com.tripsync.application.auth.OAuthSessionService import com.tripsync.common.dto.ApiResponse +import com.tripsync.common.exception.DomainException import com.tripsync.common.security.CurrentUser import com.tripsync.domain.entity.User import com.tripsync.domain.enums.AuthProvider @@ -37,7 +38,7 @@ class AuthController( fun register(@Valid @RequestBody dto: RegisterDto, response: HttpServletResponse): ApiResponse> { val email = dto.email.trim().lowercase() if (userRepository.findByEmailAndDelYn(email, YnFlag.N) != null) { - return ApiResponse.error("INVALID_REQUEST", "이미 사용 중인 이메일입니다.") + throw DomainException(HttpStatus.CONFLICT, "INVALID_REQUEST", "이미 사용 중인 이메일입니다.") } val user = userRepository.save( @@ -57,10 +58,10 @@ class AuthController( @PostMapping("/login") fun login(@Valid @RequestBody dto: LoginDto, response: HttpServletResponse): ApiResponse> { val user = userRepository.findByEmailAndDelYn(dto.email.trim().lowercase(), YnFlag.N) - ?: return ApiResponse.error("UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.") + ?: throw DomainException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.") if (user.authProvider != AuthProvider.LOCAL || user.passwordHash == null || !passwordEncoder.matches(dto.password, user.passwordHash)) { - return ApiResponse.error("UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.") + throw DomainException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "이메일 또는 비밀번호가 올바르지 않습니다.") } val token = issueSessionCookie(user, response) diff --git a/src/test/kotlin/com/tripsync/AuthContractTests.kt b/src/test/kotlin/com/tripsync/AuthContractTests.kt index 9be696b..d2ab84c 100644 --- a/src/test/kotlin/com/tripsync/AuthContractTests.kt +++ b/src/test/kotlin/com/tripsync/AuthContractTests.kt @@ -67,6 +67,43 @@ class AuthContractTests( } } + @Test + fun `duplicate register returns conflict instead of success false payload`() { + val email = "duplicate-${System.nanoTime()}@example.com" + + mockMvc.post("/auth/register") { + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"password123","nickname":"중복"}""" + }.andExpect { + status { isCreated() } + jsonPath("$.success") { value(true) } + } + + mockMvc.post("/auth/register") { + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"password123","nickname":"중복"}""" + }.andExpect { + status { isConflict() } + jsonPath("$.success") { value(false) } + jsonPath("$.error.message") { value("이미 사용 중인 이메일입니다.") } + } + } + + @Test + fun `invalid local login returns unauthorized instead of success false payload`() { + val email = "login-fail-${System.nanoTime()}@example.com" + registerSession(email, "로그인실패") + + mockMvc.post("/auth/login") { + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"wrong-password"}""" + }.andExpect { + status { isUnauthorized() } + jsonPath("$.success") { value(false) } + jsonPath("$.error.message") { value("이메일 또는 비밀번호가 올바르지 않습니다.") } + } + } + @Test fun `cookie session authenticates me and tpti submit then public share`() { val guest = mockMvc.post("/auth/guest") { From 8acd8828860f369be93b14e3c834e90b54cb9d53 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Fri, 29 May 2026 16:35:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=A9=94=ED=83=80=EC=99=80=20=EC=B0=B8=EA=B3=A0?= =?UTF-8?q?=EA=B5=B0=20=EB=AC=B8=EA=B5=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persona/PersonaValidationService.kt | 57 +++++++++-- .../schedule/ScheduleResponseMapper.kt | 18 +++- .../persona/PersonaValidationServiceTest.kt | 96 +++++++++++++++++++ .../schedule/ScheduleResponseMapperTest.kt | 28 ++++++ 4 files changed, 189 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/persona/PersonaValidationService.kt b/src/main/kotlin/com/tripsync/application/persona/PersonaValidationService.kt index fe99509..acbcde4 100644 --- a/src/main/kotlin/com/tripsync/application/persona/PersonaValidationService.kt +++ b/src/main/kotlin/com/tripsync/application/persona/PersonaValidationService.kt @@ -84,7 +84,7 @@ class PersonaValidationService( (slotScores.average() * 100).toInt().coerceIn(0, 100) } - val positiveSignals = buildPositiveSignals(option, members) + val positiveSignals = buildPositiveSignals(option, acceptanceScore) val objections = buildObjections(option, members) val persuasions = buildPersuasionPoints(option) @@ -107,10 +107,27 @@ class PersonaValidationService( } } - private fun buildPositiveSignals(option: ScheduleOptionForValidation, members: List): List { + private fun buildPositiveSignals(option: ScheduleOptionForValidation, acceptanceScore: Int): List { + val commonSlotCount = option.slots.count { it.slotType == SlotType.COMMON } + val leadingAxes = option.slots + .flatMap { slot -> slot.scores.leadingAxisLabels() } + .groupingBy { it } + .eachCount() + .entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .map { it.key } + .take(2) + + val preferenceSummary = when (leadingAxes.size) { + 0 -> "무리 없는 일정 밀도와 균형 잡힌 장소 구성이 좋게 평가됩니다." + 1 -> "${leadingAxes[0]} 성향의 여행자에게 일정 구성이 잘 맞습니다." + else -> "${leadingAxes.joinToString("·")} 성향의 여행자에게 일정 구성이 잘 맞습니다." + } + return listOf( - "${members.size}명의 취향이 ${option.slots.count { it.slotType == SlotType.COMMON }}개 공통 슬롯에서 조화를 이룹니다.", - "그룹 만족도 ${option.groupSatisfaction}점으로 안정적인 선택입니다.", + preferenceSummary, + "${commonSlotCount}개 공통 코스로 동행자와 함께 움직이기 쉽습니다.", + "참고군 기준 수용도가 ${acceptanceScore}점으로 안정적입니다.", ) } @@ -123,8 +140,34 @@ class PersonaValidationService( } private fun buildPersuasionPoints(option: ScheduleOptionForValidation): List { - return option.slots.filter { it.slotType == SlotType.PERSONAL }.map { slot -> - slot.reasonText.trim().trimEnd('.', '!', '?', '。') - }.distinct() + return option.slots + .filter { it.slotType == SlotType.PERSONAL } + .mapNotNull { slot -> slot.reasonText.toPersonaFriendlyReason() } + .distinct() + } + + private fun AxisScores.leadingAxisLabels(): List { + return listOf( + mobility to "이동을 즐기는", + photo to "사진 기록을 중시하는", + budget to "예산 효율을 보는", + theme to "테마 경험을 선호하는", + ) + .filter { (score, _) -> score >= 60 } + .sortedByDescending { (score, _) -> score } + .map { (_, label) -> label } + .take(2) + } + + private fun String.toPersonaFriendlyReason(): String? { + val cleaned = trim().trimEnd('.', '!', '?', '。').takeIf { it.isNotBlank() } ?: return null + return when { + cleaned.contains("사진") || cleaned.contains("기록") -> "사진을 남기기 좋은 장소가 포함되어 기록형 여행자에게 매력적입니다." + cleaned.contains("예산") -> "부담 없는 비용 흐름이라 실속형 여행자도 수용하기 좋습니다." + cleaned.contains("테마") -> "여행 테마가 분명해 취향이 비슷한 참고군에게 설득력이 있습니다." + cleaned.contains("이동") || cleaned.contains("활동성") -> "이동 부담을 고려한 구성이라 활동형 여행자도 따라가기 쉽습니다." + cleaned.contains("취향 반영") -> null + else -> cleaned + } } } diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt index c3e62d2..2312b61 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt @@ -175,6 +175,8 @@ class ScheduleResponseMapper( return mapOf( "imageUrl" to image.url, "imageSource" to image.source, + "fallbackImageUrl" to image.fallbackUrl, + "fallbackImageSource" to image.fallbackSource, "isRegionalBenefit" to isRegionalBenefit(place?.metadataTags, metric), "popularity" to formatPopularity(metric, place?.metadataTags), ) @@ -188,10 +190,13 @@ class ScheduleResponseMapper( private fun formatImage(place: Place?, metric: ExternalPopularityMetric?, placeId: Long): PlaceImage { val tourApiImage = place?.imageUrl?.trim()?.takeIf { it.isNotBlank() } - if (tourApiImage != null) return PlaceImage(tourApiImage, "tourapi") val hasGooglePhoto = metric?.googlePhotoReference?.isNotBlank() == true - return if (hasGooglePhoto) { - PlaceImage("${apiBaseUrl.trimEnd('/')}/places/$placeId/photo", "google_places") + val googleImage = if (hasGooglePhoto) "${apiBaseUrl.trimEnd('/')}/places/$placeId/photo" else null + + return if (tourApiImage != null) { + PlaceImage(tourApiImage, "tourapi", googleImage, if (googleImage != null) "google_places" else null) + } else if (googleImage != null) { + PlaceImage(googleImage, "google_places") } else { PlaceImage(null, null) } @@ -215,6 +220,11 @@ class ScheduleResponseMapper( "role" to role, "label" to label, "hasExternalSignal" to (score != null), + "normalizedPopularityScore" to score, + "naverSearchTrendScore" to metric?.naverSearchTrendScore, + "googleRating" to metric?.googleRating?.toDouble(), + "googleUserRatingCount" to metric?.googleUserRatingCount, + "sourceUpdatedAt" to metric?.updatedAt?.toString(), ) } @@ -231,5 +241,7 @@ class ScheduleResponseMapper( private data class PlaceImage( val url: String?, val source: String?, + val fallbackUrl: String? = null, + val fallbackSource: String? = null, ) } diff --git a/src/test/kotlin/com/tripsync/application/persona/PersonaValidationServiceTest.kt b/src/test/kotlin/com/tripsync/application/persona/PersonaValidationServiceTest.kt index 630f093..7f7efaf 100644 --- a/src/test/kotlin/com/tripsync/application/persona/PersonaValidationServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/persona/PersonaValidationServiceTest.kt @@ -56,6 +56,54 @@ class PersonaValidationServiceTest { assertTrue(result.isEmpty()) } + @Test + fun `validation explains why similar personas would like the option`() { + val service = PersonaValidationService( + personaVectorServiceWith( + PersonaVectorService.PersonaVectorData( + uuid = UUID.randomUUID(), + scores = AxisScores(mobility = 80, photo = 75, budget = 55, theme = 45), + summary = "사진과 이동을 즐기는 참고군", + ) + ) + ) + + val result = service.validateOptions( + options = listOf( + PersonaValidationService.ScheduleOptionForValidation( + optionType = ScheduleOptionType.INDIVIDUAL, + groupSatisfaction = 85, + slots = listOf( + PersonaValidationService.ScheduleSlotForValidation( + slotType = SlotType.COMMON, + reasonText = "공통 취향 반영", + scores = AxisScores(mobility = 80, photo = 75, budget = 40, theme = 50), + ), + PersonaValidationService.ScheduleSlotForValidation( + slotType = SlotType.PERSONAL, + reasonText = "사진 취향 반영", + scores = AxisScores(mobility = 65, photo = 90, budget = 50, theme = 55), + ), + ), + satisfactionByUser = listOf( + PersonaValidationService.SatisfactionForValidation(userId = 1L, score = 85), + ), + ) + ), + members = listOf( + PersonaValidationService.MemberSnapshot( + userId = 1L, + scores = AxisScores(mobility = 82, photo = 76, budget = 50, theme = 45), + ) + ), + )[ScheduleOptionType.INDIVIDUAL]!! + + assertTrue(result.topPositiveSignals.any { it.contains("사진 기록") || it.contains("이동") }) + assertTrue(result.topPositiveSignals.any { it.contains("${result.personaAcceptanceScore}점") }) + assertTrue(result.topPositiveSignals.none { it.contains("취향 반영") }) + assertEquals("사진을 남기기 좋은 장소가 포함되어 기록형 여행자에게 매력적입니다.", result.persuasionPoints.single()) + } + private fun optionWithSlotScores(scores: AxisScores): PersonaValidationService.ScheduleOptionForValidation { return PersonaValidationService.ScheduleOptionForValidation( optionType = ScheduleOptionType.BALANCED, @@ -83,4 +131,52 @@ class PersonaValidationServiceTest { field.set(service, vectors.toList()) return service } + + @Test + fun `personal reason text is converted to persona benefit copy without exposing member names`() { + val service = PersonaValidationService( + personaVectorServiceWith( + PersonaVectorService.PersonaVectorData( + uuid = UUID.randomUUID(), + scores = AxisScores(mobility = 60, photo = 70, budget = 50, theme = 65), + summary = "기록형 참고군", + ) + ) + ) + + val result = service.validateOptions( + options = listOf( + PersonaValidationService.ScheduleOptionForValidation( + optionType = ScheduleOptionType.BALANCED, + groupSatisfaction = 79, + slots = listOf( + PersonaValidationService.ScheduleSlotForValidation( + slotType = SlotType.COMMON, + reasonText = "그룹 전원의 평균 취향 반영", + scores = AxisScores(mobility = 65, photo = 70, budget = 50, theme = 60), + ), + PersonaValidationService.ScheduleSlotForValidation( + slotType = SlotType.PERSONAL, + reasonText = "hee의 기록 취향 반영", + scores = AxisScores(mobility = 55, photo = 75, budget = 50, theme = 65), + ), + ), + satisfactionByUser = listOf( + PersonaValidationService.SatisfactionForValidation(userId = 1, score = 79), + ), + ), + ), + members = listOf( + PersonaValidationService.MemberSnapshot( + userId = 1, + scores = AxisScores(mobility = 60, photo = 70, budget = 50, theme = 65), + ), + ), + )[ScheduleOptionType.BALANCED]!! + + assertTrue(result.topPositiveSignals.any { it.contains(result.personaAcceptanceScore.toString()) }) + assertTrue(result.topPositiveSignals.none { it.contains("그룹 만족도") }) + assertEquals(listOf("사진을 남기기 좋은 장소가 포함되어 기록형 여행자에게 매력적입니다."), result.persuasionPoints) + assertTrue(result.persuasionPoints.none { it.contains("hee") || it.contains("취향 반영") }) + } } diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt index 43510ff..594c117 100644 --- a/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt @@ -60,6 +60,9 @@ class ScheduleResponseMapperTest { val metric = ExternalPopularityMetric( place = place, normalizedPopularityScore = 82, + naverSearchTrendScore = 64, + googleRating = BigDecimal("4.5"), + googleUserRatingCount = 182, collectedAt = Instant.parse("2026-05-20T00:00:00Z"), ) `when`(externalPopularityMetricRepository.findByPlaceId(place.id)).thenReturn(metric) @@ -69,8 +72,15 @@ class ScheduleResponseMapperTest { assertEquals("https://tour.example/image.jpg", response["imageUrl"]) assertEquals("tourapi", response["imageSource"]) + assertNull(response["fallbackImageUrl"]) + assertNull(response["fallbackImageSource"]) assertEquals("popular_anchor", popularity["role"]) assertFalse(popularity.containsKey("score")) + assertEquals(82, popularity["normalizedPopularityScore"]) + assertEquals(64, popularity["naverSearchTrendScore"]) + assertEquals(4.5, popularity["googleRating"]) + assertEquals(182, popularity["googleUserRatingCount"]) + assertEquals(metric.updatedAt.toString(), popularity["sourceUpdatedAt"]) } @Test @@ -89,6 +99,24 @@ class ScheduleResponseMapperTest { assertEquals("google_places", response["imageSource"]) } + @Test + fun `place response includes Google fallback image when TourAPI image exists`() { + val place = place(imageUrl = "https://tour.example/image.jpg") + val metric = ExternalPopularityMetric( + place = place, + googlePhotoReference = "photo-ref", + collectedAt = Instant.parse("2026-05-20T00:00:00Z"), + ) + `when`(externalPopularityMetricRepository.findByPlaceId(place.id)).thenReturn(metric) + + val response = mapper.formatPlace(place, place.id, place.name, place.address) + + assertEquals("https://tour.example/image.jpg", response["imageUrl"]) + assertEquals("tourapi", response["imageSource"]) + assertEquals("http://localhost:8080/api/places/${place.id}/photo", response["fallbackImageUrl"]) + assertEquals("google_places", response["fallbackImageSource"]) + } + @Test fun `place response marks external signal missing without excluding place`() { val place = place(imageUrl = null) From 713cfdb74ee92f721f36ce28e8be0994d433cca4 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 24 Jun 2026 14:44:18 +0900 Subject: [PATCH 5/5] fix: harden auth and public share links --- .../application/auth/JwtTokenProvider.kt | 25 ++++++ .../application/auth/OAuthSessionService.kt | 8 ++ .../schedule/ScheduleResponseMapper.kt | 2 + .../application/schedule/ScheduleService.kt | 6 +- .../tripsync/application/tpti/TptiService.kt | 8 +- .../common/security/ShareTokenGenerator.kt | 15 ++++ .../com/tripsync/domain/entity/Schedule.kt | 4 + .../com/tripsync/domain/entity/TptiResult.kt | 4 + .../domain/repository/ScheduleRepository.kt | 1 + .../domain/repository/TptiResultRepository.kt | 1 + .../com/tripsync/web/auth/AuthController.kt | 10 ++- .../web/schedule/ScheduleController.kt | 6 +- .../com/tripsync/web/tpti/TptiController.kt | 6 +- src/main/resources/application-local.yml | 7 ++ src/main/resources/application.yml | 12 ++- .../V10__add_public_share_tokens.sql | 22 +++++ .../kotlin/com/tripsync/AuthContractTests.kt | 8 +- .../auth/SecurityConfigurationUnitTest.kt | 82 +++++++++++++++++++ 18 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 src/main/kotlin/com/tripsync/common/security/ShareTokenGenerator.kt create mode 100644 src/main/resources/db/migration/V10__add_public_share_tokens.sql create mode 100644 src/test/kotlin/com/tripsync/application/auth/SecurityConfigurationUnitTest.kt diff --git a/src/main/kotlin/com/tripsync/application/auth/JwtTokenProvider.kt b/src/main/kotlin/com/tripsync/application/auth/JwtTokenProvider.kt index 87864d8..b37bb49 100644 --- a/src/main/kotlin/com/tripsync/application/auth/JwtTokenProvider.kt +++ b/src/main/kotlin/com/tripsync/application/auth/JwtTokenProvider.kt @@ -3,7 +3,9 @@ package com.tripsync.application.auth import io.jsonwebtoken.Claims import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys +import jakarta.annotation.PostConstruct import org.springframework.beans.factory.annotation.Value +import org.springframework.core.env.Environment import org.springframework.stereotype.Component import java.security.MessageDigest import java.util.* @@ -15,11 +17,19 @@ class JwtTokenProvider( private val secret: String, @Value("\${jwt.expiration:604800000}") private val expiration: Long, + private val environment: Environment, ) { companion object { private const val MIN_HS256_KEY_BYTES = 32 + private val DEV_PROFILES = setOf("local", "test") + private val WEAK_DEFAULT_SECRETS = setOf( + "your-256-bit-secret-key-here-for-development-only", + "local-development-secret-key-256-bits-min", + "test-development-secret-key-256-bits-min", + ) } private val key: SecretKey by lazy { + validateSecret() val rawBytes = secret.toByteArray(Charsets.UTF_8) val keyBytes = if (rawBytes.size >= MIN_HS256_KEY_BYTES) { rawBytes @@ -29,6 +39,11 @@ class JwtTokenProvider( Keys.hmacShaKeyFor(keyBytes) } + @PostConstruct + fun validateConfiguration() { + validateSecret() + } + fun generateToken(userId: Long, isGuest: Boolean): String { val now = Date() val expiry = Date(now.time + expiration) @@ -66,4 +81,14 @@ class JwtTokenProvider( .parseSignedClaims(token) .payload } + + private fun validateSecret() { + val normalized = secret.trim() + require(normalized.isNotBlank()) { "JWT_SECRET must be configured" } + val activeProfiles = environment.activeProfiles.toSet() + val isDevProfile = activeProfiles.any { it in DEV_PROFILES } + require(isDevProfile || normalized !in WEAK_DEFAULT_SECRETS) { + "JWT_SECRET must not use a development default outside local/test profiles" + } + } } diff --git a/src/main/kotlin/com/tripsync/application/auth/OAuthSessionService.kt b/src/main/kotlin/com/tripsync/application/auth/OAuthSessionService.kt index 8b9a6ad..3a7002c 100644 --- a/src/main/kotlin/com/tripsync/application/auth/OAuthSessionService.kt +++ b/src/main/kotlin/com/tripsync/application/auth/OAuthSessionService.kt @@ -35,6 +35,7 @@ class OAuthSessionService( @Value("\${kakao.authorize-url:https://kauth.kakao.com/oauth/authorize}") private val kakaoAuthorizeUrl: String, @Value("\${kakao.token-url:https://kauth.kakao.com/oauth/token}") private val kakaoTokenUrl: String, @Value("\${kakao.user-info-url:https://kapi.kakao.com/v2/user/me}") private val kakaoUserInfoUrl: String, + @Value("\${oauth.dev-fallback-enabled:false}") private val devFallbackEnabled: Boolean, ) { data class OAuthRedirect(val state: String, val redirectUrl: String) data class OAuthResult(val user: User, val redirectUrl: String) @@ -48,6 +49,7 @@ class OAuthSessionService( val clientId = clientId(provider) if (clientId.isBlank()) { + ensureDevOAuthFallbackAllowed(provider) return OAuthRedirect( state = state, redirectUrl = "$callbackUrl?code=local-${provider.name.lowercase()}-code&state=${enc(combinedState)}&redirectPath=${enc(normalizedRedirectPath)}", @@ -91,6 +93,7 @@ class OAuthSessionService( private fun fetchOAuthProfile(provider: AuthProvider, code: String): OAuthProfile { if (clientId(provider).isBlank()) { + ensureDevOAuthFallbackAllowed(provider) return OAuthProfile( providerUserId = code, nickname = if (provider == AuthProvider.KAKAO) "kakao-host" else "google-host", @@ -189,6 +192,11 @@ class OAuthSessionService( private fun userInfoUrl(provider: AuthProvider) = if (provider == AuthProvider.KAKAO) kakaoUserInfoUrl else googleUserInfoUrl private fun clientId(provider: AuthProvider) = if (provider == AuthProvider.KAKAO) kakaoClientId else googleClientId private fun clientSecret(provider: AuthProvider) = if (provider == AuthProvider.KAKAO) kakaoClientSecret else googleClientSecret + private fun ensureDevOAuthFallbackAllowed(provider: AuthProvider) { + if (!devFallbackEnabled) { + throw DomainException(HttpStatus.SERVICE_UNAVAILABLE, "OAUTH_NOT_CONFIGURED", "${provider.name.lowercase()} OAuth 설정이 필요합니다.") + } + } private fun enc(value: String) = URLEncoder.encode(value, StandardCharsets.UTF_8) private fun query(params: Map) = params.entries.joinToString("&") { "${enc(it.key)}=${enc(it.value)}" } } diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt index 2312b61..0d0ee7c 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt @@ -71,6 +71,7 @@ class ScheduleResponseMapper( val metricsByPlaceId = loadMetricsByPlaceId(activeSlots.map { it.place.id }) return mapOf( "id" to schedule.id, + "shareToken" to schedule.shareToken, "roomId" to schedule.room.id, "destination" to schedule.room.destination, "tripDate" to schedule.room.tripStartDate.toString(), @@ -122,6 +123,7 @@ class ScheduleResponseMapper( fun formatPublicShareSchedule(schedule: Schedule): Map { val publicKeys = setOf( "id", + "shareToken", "roomId", "destination", "tripDate", diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt index 0f6fa4a..88b3203 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt @@ -209,6 +209,7 @@ class ScheduleService( return ApiResponse.ok( mapOf( "scheduleId" to target.id, + "shareToken" to target.shareToken, "roomId" to roomId, "optionType" to target.optionType.name.lowercase(), "status" to "confirmed", @@ -244,8 +245,9 @@ class ScheduleService( } @Transactional(readOnly = true) - fun getPublicShareSchedule(scheduleId: Long): ApiResponse> { - val schedule = accessPolicy.getActiveSchedule(scheduleId) + fun getPublicShareSchedule(shareToken: String): ApiResponse> { + val schedule = scheduleRepository.findByShareTokenAndDelYn(shareToken, YnFlag.N) + ?: throw DomainException(HttpStatus.NOT_FOUND, "SCHEDULE_NOT_FOUND", "공유 일정을 찾을 수 없습니다.") return ApiResponse.ok(responseMapper.formatPublicShareSchedule(schedule)) } diff --git a/src/main/kotlin/com/tripsync/application/tpti/TptiService.kt b/src/main/kotlin/com/tripsync/application/tpti/TptiService.kt index 19a1e95..b003eee 100644 --- a/src/main/kotlin/com/tripsync/application/tpti/TptiService.kt +++ b/src/main/kotlin/com/tripsync/application/tpti/TptiService.kt @@ -58,6 +58,7 @@ class TptiService( return ApiResponse.ok( mapOf( "resultId" to result.id, + "shareToken" to result.shareToken, "userId" to user.id, "scores" to mapOf( "mobility" to result.mobilityScore, @@ -83,9 +84,9 @@ class TptiService( } @Transactional(readOnly = true) - fun getPublicShareResult(resultId: Long): ApiResponse> { - val result = tptiResultRepository.findById(resultId).orElse(null) - if (result == null || result.delYn != YnFlag.N) { + fun getPublicShareResult(shareToken: String): ApiResponse> { + val result = tptiResultRepository.findByShareTokenAndDelYn(shareToken, YnFlag.N) + if (result == null) { throw DomainException(HttpStatus.NOT_FOUND, "RESOURCE_DELETED", "공유할 TPTI 결과를 찾을 수 없습니다.") } return ApiResponse.ok(resultResponse(result) + mapOf("nickname" to result.user.nickname)) @@ -136,6 +137,7 @@ class TptiService( private fun resultResponse(result: TptiResult): Map = mapOf( "resultId" to result.id, + "shareToken" to result.shareToken, "userId" to result.user.id, "scores" to mapOf( "mobility" to result.mobilityScore, diff --git a/src/main/kotlin/com/tripsync/common/security/ShareTokenGenerator.kt b/src/main/kotlin/com/tripsync/common/security/ShareTokenGenerator.kt new file mode 100644 index 0000000..3032f3f --- /dev/null +++ b/src/main/kotlin/com/tripsync/common/security/ShareTokenGenerator.kt @@ -0,0 +1,15 @@ +package com.tripsync.common.security + +import java.security.SecureRandom +import java.util.Base64 + +object ShareTokenGenerator { + private val random = SecureRandom() + private val encoder = Base64.getUrlEncoder().withoutPadding() + + fun generate(byteLength: Int = 18): String { + val bytes = ByteArray(byteLength) + random.nextBytes(bytes) + return encoder.encodeToString(bytes) + } +} diff --git a/src/main/kotlin/com/tripsync/domain/entity/Schedule.kt b/src/main/kotlin/com/tripsync/domain/entity/Schedule.kt index 58047f5..db9115c 100644 --- a/src/main/kotlin/com/tripsync/domain/entity/Schedule.kt +++ b/src/main/kotlin/com/tripsync/domain/entity/Schedule.kt @@ -1,5 +1,6 @@ package com.tripsync.domain.entity +import com.tripsync.common.security.ShareTokenGenerator import com.tripsync.domain.enums.ScheduleOptionType import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode @@ -47,6 +48,9 @@ class Schedule( @Column(name = "llm_provider", length = 50) var llmProvider: String? = null, + + @Column(name = "share_token", nullable = false, unique = true, length = 64) + var shareToken: String = ShareTokenGenerator.generate(), ) : BaseEntity() { @OneToMany(mappedBy = "schedule", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val slots: MutableList = mutableListOf() diff --git a/src/main/kotlin/com/tripsync/domain/entity/TptiResult.kt b/src/main/kotlin/com/tripsync/domain/entity/TptiResult.kt index 2ef47c9..0985174 100644 --- a/src/main/kotlin/com/tripsync/domain/entity/TptiResult.kt +++ b/src/main/kotlin/com/tripsync/domain/entity/TptiResult.kt @@ -1,5 +1,6 @@ package com.tripsync.domain.entity +import com.tripsync.common.security.ShareTokenGenerator import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode import org.hibernate.type.SqlTypes @@ -36,4 +37,7 @@ class TptiResult( @Column(name = "is_manually_adjusted", nullable = false) var isManuallyAdjusted: Boolean = false, + + @Column(name = "share_token", nullable = false, unique = true, length = 64) + var shareToken: String = ShareTokenGenerator.generate(), ) : BaseEntity() diff --git a/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt index 3519db2..7d6c32d 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt @@ -12,6 +12,7 @@ interface ScheduleRepository : JpaRepository { fun findByRoomIdAndDelYn(roomId: Long, delYn: YnFlag): List fun findByRoomId(roomId: Long): List fun findTopByRoomIdAndDelYnOrderByVersionDesc(roomId: Long, delYn: YnFlag): Schedule? + fun findByShareTokenAndDelYn(shareToken: String, delYn: YnFlag): Schedule? @Query( """ diff --git a/src/main/kotlin/com/tripsync/domain/repository/TptiResultRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/TptiResultRepository.kt index 49362f2..6c14081 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/TptiResultRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/TptiResultRepository.kt @@ -8,4 +8,5 @@ import org.springframework.stereotype.Repository @Repository interface TptiResultRepository : JpaRepository { fun findTopByUserIdAndDelYnOrderByCreatedAtDesc(userId: Long, delYn: YnFlag): TptiResult? + fun findByShareTokenAndDelYn(shareToken: String, delYn: YnFlag): TptiResult? } diff --git a/src/main/kotlin/com/tripsync/web/auth/AuthController.kt b/src/main/kotlin/com/tripsync/web/auth/AuthController.kt index 25825b8..a0d7429 100644 --- a/src/main/kotlin/com/tripsync/web/auth/AuthController.kt +++ b/src/main/kotlin/com/tripsync/web/auth/AuthController.kt @@ -16,6 +16,7 @@ import com.tripsync.web.dto.LoginDto import com.tripsync.web.dto.RegisterDto import jakarta.servlet.http.HttpServletResponse import jakarta.validation.Valid +import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseCookie @@ -31,6 +32,7 @@ class AuthController( private val passwordEncoder: PasswordEncoder, private val guestSessionService: GuestSessionService, private val oAuthSessionService: OAuthSessionService, + @Value("\${security.cookie.secure:false}") private val secureCookies: Boolean, ) { @PostMapping("/register") @@ -159,7 +161,7 @@ class AuthController( private fun sessionCookie(token: String): ResponseCookie = ResponseCookie .from(JwtAuthenticationFilter.SESSION_COOKIE_NAME, token) .httpOnly(true) - .secure(false) + .secure(secureCookies) .sameSite("Lax") .path("/") .maxAge(Duration.ofDays(7)) @@ -168,7 +170,7 @@ class AuthController( private fun expiredSessionCookie(): ResponseCookie = ResponseCookie .from(JwtAuthenticationFilter.SESSION_COOKIE_NAME, "") .httpOnly(true) - .secure(false) + .secure(secureCookies) .sameSite("Lax") .path("/") .maxAge(Duration.ZERO) @@ -177,7 +179,7 @@ class AuthController( private fun oauthStateCookie(state: String): ResponseCookie = ResponseCookie .from(OAUTH_STATE_COOKIE_NAME, state) .httpOnly(true) - .secure(false) + .secure(secureCookies) .sameSite("Lax") .path("/") .maxAge(Duration.ofMinutes(10)) @@ -186,7 +188,7 @@ class AuthController( private fun expiredOAuthStateCookie(): ResponseCookie = ResponseCookie .from(OAUTH_STATE_COOKIE_NAME, "") .httpOnly(true) - .secure(false) + .secure(secureCookies) .sameSite("Lax") .path("/") .maxAge(Duration.ZERO) diff --git a/src/main/kotlin/com/tripsync/web/schedule/ScheduleController.kt b/src/main/kotlin/com/tripsync/web/schedule/ScheduleController.kt index 0520109..9e27db9 100644 --- a/src/main/kotlin/com/tripsync/web/schedule/ScheduleController.kt +++ b/src/main/kotlin/com/tripsync/web/schedule/ScheduleController.kt @@ -101,8 +101,8 @@ class ScheduleController( return scheduleService.regenerateSchedule(scheduleId, user.id, dto) } - @GetMapping("/share/schedules/{scheduleId}") - fun getPublicShareSchedule(@PathVariable scheduleId: Long): ApiResponse> { - return scheduleService.getPublicShareSchedule(scheduleId) + @GetMapping("/share/schedules/{shareToken}") + fun getPublicShareSchedule(@PathVariable shareToken: String): ApiResponse> { + return scheduleService.getPublicShareSchedule(shareToken) } } diff --git a/src/main/kotlin/com/tripsync/web/tpti/TptiController.kt b/src/main/kotlin/com/tripsync/web/tpti/TptiController.kt index 9a99a0c..786de2f 100644 --- a/src/main/kotlin/com/tripsync/web/tpti/TptiController.kt +++ b/src/main/kotlin/com/tripsync/web/tpti/TptiController.kt @@ -30,8 +30,8 @@ class TptiController( return tptiService.getLatestResult(userId, user) } - @GetMapping("/share/tpti/{resultId}") - fun getShareResult(@PathVariable resultId: Long): ApiResponse> { - return tptiService.getPublicShareResult(resultId) + @GetMapping("/share/tpti/{shareToken}") + fun getShareResult(@PathVariable shareToken: String): ApiResponse> { + return tptiService.getPublicShareResult(shareToken) } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 084d5e9..9be525a 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -61,6 +61,13 @@ jwt: secret: ${JWT_SECRET:local-development-secret-key-256-bits-min} expiration: 604800000 +security: + cookie: + secure: ${COOKIE_SECURE:false} + +oauth: + dev-fallback-enabled: ${OAUTH_DEV_FALLBACK_ENABLED:true} + openai: api-key: ${OPENAI_API_KEY:} model: ${OPENAI_MODEL:gpt-4o-mini} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 03afe0f..7c48ba2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,9 +61,13 @@ logging: org.hibernate.type.descriptor.sql.BasicBinder: TRACE jwt: - secret: ${JWT_SECRET:your-256-bit-secret-key-here-for-development-only} + secret: "${JWT_SECRET:}" expiration: 604800000 +security: + cookie: + secure: "${COOKIE_SECURE:true}" + openai: api-key: ${OPENAI_API_KEY:} model: ${OPENAI_MODEL:gpt-4o-mini} @@ -141,3 +145,9 @@ spring: ddl-auto: create-drop flyway: enabled: false + +jwt: + secret: test-development-secret-key-256-bits-min + +oauth: + dev-fallback-enabled: true diff --git a/src/main/resources/db/migration/V10__add_public_share_tokens.sql b/src/main/resources/db/migration/V10__add_public_share_tokens.sql new file mode 100644 index 0000000..746725e --- /dev/null +++ b/src/main/resources/db/migration/V10__add_public_share_tokens.sql @@ -0,0 +1,22 @@ +ALTER TABLE schedules + ADD COLUMN share_token VARCHAR(64); + +ALTER TABLE tpti_results + ADD COLUMN share_token VARCHAR(64); + +UPDATE schedules +SET share_token = md5(random()::text || clock_timestamp()::text || id::text) +WHERE share_token IS NULL; + +UPDATE tpti_results +SET share_token = md5(random()::text || clock_timestamp()::text || id::text) +WHERE share_token IS NULL; + +ALTER TABLE schedules + ALTER COLUMN share_token SET NOT NULL; + +ALTER TABLE tpti_results + ALTER COLUMN share_token SET NOT NULL; + +CREATE UNIQUE INDEX uq_schedules_share_token ON schedules(share_token); +CREATE UNIQUE INDEX uq_tpti_results_share_token ON tpti_results(share_token); diff --git a/src/test/kotlin/com/tripsync/AuthContractTests.kt b/src/test/kotlin/com/tripsync/AuthContractTests.kt index d2ab84c..ab2113c 100644 --- a/src/test/kotlin/com/tripsync/AuthContractTests.kt +++ b/src/test/kotlin/com/tripsync/AuthContractTests.kt @@ -127,13 +127,11 @@ class AuthContractTests( jsonPath("$.data.resultId") { value(notNullValue()) } }.andReturn().response.contentAsString - val resultId = Regex("\\\"result_id\\\":(\\d+)|\\\"resultId\\\":(\\d+)") + val shareToken = Regex("\\\"shareToken\\\":\\\"([^\\\"]+)\\\"") .find(submit)!! - .groupValues - .drop(1) - .first { it.isNotBlank() } + .groupValues[1] - mockMvc.get("/share/tpti/$resultId") + mockMvc.get("/share/tpti/$shareToken") .andExpect { status { isOk() } jsonPath("$.data.nickname") { value("테스터") } diff --git a/src/test/kotlin/com/tripsync/application/auth/SecurityConfigurationUnitTest.kt b/src/test/kotlin/com/tripsync/application/auth/SecurityConfigurationUnitTest.kt new file mode 100644 index 0000000..823d42e --- /dev/null +++ b/src/test/kotlin/com/tripsync/application/auth/SecurityConfigurationUnitTest.kt @@ -0,0 +1,82 @@ +package com.tripsync.application.auth + +import com.fasterxml.jackson.databind.ObjectMapper +import com.tripsync.common.exception.DomainException +import com.tripsync.domain.enums.AuthProvider +import com.tripsync.domain.repository.UserRepository +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.springframework.mock.env.MockEnvironment +import org.springframework.web.reactive.function.client.WebClient + +class SecurityConfigurationUnitTest { + @Test + fun `oauth fallback is rejected when provider client id is blank and fallback is disabled`() { + val service = OAuthSessionService( + userRepository = mock(UserRepository::class.java), + webClient = WebClient.create(), + objectMapper = ObjectMapper(), + frontendBaseUrl = "http://localhost:3001", + oauthCallbackBaseUrl = "http://localhost:8080", + googleClientId = "", + googleClientSecret = "", + googleAuthorizeUrl = "https://accounts.google.com/o/oauth2/v2/auth", + googleTokenUrl = "https://oauth2.googleapis.com/token", + googleUserInfoUrl = "https://openidconnect.googleapis.com/v1/userinfo", + kakaoClientId = "", + kakaoClientSecret = "", + kakaoAuthorizeUrl = "https://kauth.kakao.com/oauth/authorize", + kakaoTokenUrl = "https://kauth.kakao.com/oauth/token", + kakaoUserInfoUrl = "https://kapi.kakao.com/v2/user/me", + devFallbackEnabled = false, + ) + + val error = assertThrows(DomainException::class.java) { + service.buildRedirect(AuthProvider.GOOGLE, "/rooms/new") + } + + assertEquals("OAUTH_NOT_CONFIGURED", error.code) + } + + @Test + fun `jwt provider rejects blank secret`() { + val provider = JwtTokenProvider( + secret = "", + expiration = 604800000L, + environment = MockEnvironment().apply { setActiveProfiles("prod") }, + ) + + assertThrows(IllegalArgumentException::class.java) { + provider.generateToken(1L, false) + } + } + + @Test + fun `jwt provider rejects development default outside local or test profiles`() { + val provider = JwtTokenProvider( + secret = "local-development-secret-key-256-bits-min", + expiration = 604800000L, + environment = MockEnvironment().apply { setActiveProfiles("prod") }, + ) + + assertThrows(IllegalArgumentException::class.java) { + provider.generateToken(1L, false) + } + } + + @Test + fun `jwt provider allows development default in test profile`() { + val provider = JwtTokenProvider( + secret = "test-development-secret-key-256-bits-min", + expiration = 604800000L, + environment = MockEnvironment().apply { setActiveProfiles("test") }, + ) + + assertDoesNotThrow { + provider.generateToken(1L, false) + } + } +}