From cc7135613eb76deffcca34e6a1973bc4dd35f7c8 Mon Sep 17 00:00:00 2001 From: kobenlys Date: Wed, 16 Sep 2026 00:25:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EB=AF=B8=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=B0=B0=EC=A7=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=ED=85=8C=EB=A7=88=EC=99=80=20=EB=AC=B4?= =?UTF-8?q?=EA=B4=80=ED=95=98=EA=B2=8C=20404=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미가입 username은 이제 모든 테마에서 404 BADGE_001을 받는다. 가입했지만 자격이 없는 경우는 기존대로 200 잠금 SVG다. 기존에는 자격이 필요한 테마(beta-tester)만 200 잠금 SVG를 반환했다. 자격 게이트가 사용자 조회보다 먼저 걸려 오타 username이 걸러지지 않았다. findGrant 쿼리에 userExists 컬럼을 더해 DB 왕복을 늘리지 않고 판별하며, BadgeRenderService가 자격 분기보다 앞에서 BADGE_001을 던진다. --- .../repository/UserBadgeGrantRepository.java | 10 +++- .../repository/projection/BadgeGrantRow.java | 3 + .../badge/service/BadgeGrantResult.java | 27 +++++++-- .../badge/service/BadgeGrantService.java | 9 ++- .../badge/controller/BadgeController.java | 1 + .../badge/service/BadgeRenderService.java | 23 +++++--- .../badge/service/BadgeGrantServiceTest.java | 55 +++++++++++++++++-- .../badge/controller/BadgeControllerTest.java | 19 +++++++ .../badge/service/BadgeRenderServiceTest.java | 23 ++++++++ ...erBadgeGrantRepositoryIntegrationTest.java | 38 ++++++++++++- 10 files changed, 182 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/UserBadgeGrantRepository.java b/src/main/java/com/tokenphage/api/domain/badge/repository/UserBadgeGrantRepository.java index b723592..c3ca2d8 100644 --- a/src/main/java/com/tokenphage/api/domain/badge/repository/UserBadgeGrantRepository.java +++ b/src/main/java/com/tokenphage/api/domain/badge/repository/UserBadgeGrantRepository.java @@ -13,12 +13,13 @@ public interface UserBadgeGrantRepository /** * 배지 사용 가능 여부를 DB 왕복 한 번으로 확인한다. *

- * 자격 필요 여부와 자격 보유를 한 쿼리에서 본다. 공개 배지면 EXISTS는 실행되지 않는다. + * 자격 필요 여부와 자격 보유를 한 쿼리에서 본다. 공개 배지면 자격 EXISTS는 실행되지 않는다. * 자격은 github_id 기준이고 요청은 username이라 users를 조인한다. + * 사용자 존재 여부(userExists)도 같은 왕복에서 본다. * * @param username 배지 주인 GitHub 사용자명 (null 불허) * @param badgeCode 정리된 배지 코드 (null 불허) - * @return 사용 가능 여부와 안내 문구. 등록되지 않은 배지 코드면 null + * @return 사용 가능 여부·사용자 존재 여부·안내 문구. 등록되지 않은 배지 코드면 null * @Since 2026-08-10 */ @Query(value = """ @@ -29,6 +30,11 @@ public interface UserBadgeGrantRepository WHERE u.username = :username AND ubg.badge_code = c.code ) AS granted, + EXISTS ( + SELECT 1 + FROM users u2 + WHERE u2.username = :username + ) AS userExists, c.display_name AS displayName, c.locked_message AS lockedMessage FROM badge_catalog c diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/projection/BadgeGrantRow.java b/src/main/java/com/tokenphage/api/domain/badge/repository/projection/BadgeGrantRow.java index 08ad438..66b4752 100644 --- a/src/main/java/com/tokenphage/api/domain/badge/repository/projection/BadgeGrantRow.java +++ b/src/main/java/com/tokenphage/api/domain/badge/repository/projection/BadgeGrantRow.java @@ -21,4 +21,7 @@ public interface BadgeGrantRow { /** 자격이 없을 때 보여줄 안내 문구. NULL이면 호출 측이 기본 문구를 쓴다. */ String getLockedMessage(); + + /** 배지 주인이 가입된 사용자면 true. primitive다 — EXISTS는 NULL을 반환하지 않는다. */ + boolean getUserExists(); } diff --git a/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantResult.java b/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantResult.java index e14008f..7307389 100644 --- a/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantResult.java +++ b/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantResult.java @@ -3,11 +3,12 @@ /** * 배지 사용 가능 여부 확인 결과. * - * @param granted 쓸 수 있으면 true - * @param title 잠금 안내 제목(배지 이름). 거부일 때만 채워진다 - * @param message 잠금 안내 문구. null이면 받는 쪽이 기본 문구로 채운다 + * @param granted 쓸 수 있으면 true + * @param userExists 배지 주인이 가입된 사용자면 true. false면 받는 쪽이 404로 끊는다 + * @param title 잠금 안내 제목(배지 이름). 거부일 때만 채워진다 + * @param message 잠금 안내 문구. null이면 받는 쪽이 기본 문구로 채운다 */ -public record BadgeGrantResult(boolean granted, String title, String message) { +public record BadgeGrantResult(boolean granted, boolean userExists, String title, String message) { /** * 사용 가능 결과를 만든다. @@ -18,11 +19,13 @@ public record BadgeGrantResult(boolean granted, String title, String message) { * @Since 2026-08-10 */ public static BadgeGrantResult allow() { - return new BadgeGrantResult(true, null, null); + return new BadgeGrantResult(true, true, null, null); } /** * 거부 결과를 안내 문구와 함께 만든다. + *

+ * 배지 주인은 가입돼 있고 자격만 없는 상태다. 받는 쪽은 잠금 안내를 그린다. * * @param title 배지 이름 (null이면 기본 제목으로 대체) * @param message 안내 문구 (null이면 기본 문구로 대체) @@ -30,6 +33,18 @@ public static BadgeGrantResult allow() { * @Since 2026-08-10 */ public static BadgeGrantResult deny(String title, String message) { - return new BadgeGrantResult(false, title, message); + return new BadgeGrantResult(false, true, title, message); + } + + /** + * 가입되지 않은 사용자라는 결과를 만든다. + *

+ * 자격 거부와 구분된다. 잠금 안내를 그릴 대상이 아니므로 문구를 싣지 않는다. + * + * @return 미가입 결과 + * @Since 2026-09-06 + */ + public static BadgeGrantResult userNotFound() { + return new BadgeGrantResult(false, false, null, null); } } diff --git a/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantService.java b/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantService.java index 2360838..ccf03f3 100644 --- a/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantService.java +++ b/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantService.java @@ -23,18 +23,25 @@ public class BadgeGrantService { *

* 판단 기준은 뷰어가 아니라 /badge/{username}의 주인이다. * 등록되지 않은 코드는 거부한다. 안내 문구 표시는 feature/badge가 정한다. + * 가입되지 않은 주인은 자격 거부보다 먼저 가려낸다 — 공개 배지는 미가입이어도 granted가 참이라 + * granted만으로는 구분되지 않는다. 미가입을 어떤 응답으로 바꿀지는 feature/badge가 정한다. * * @param username 배지 주인 GitHub 사용자명 (null 불허) * @param badgeCode 정리된 배지 코드 (null 불허) - * @return 사용 가능 여부와 안내 문구 + * @return 사용 가능 여부·사용자 존재 여부와 안내 문구 * @Since 2026-08-10 */ public BadgeGrantResult resolveGrant(String username, String badgeCode) { BadgeGrantRow row = grantRepo.findGrant(username, badgeCode); if (row == null) { + // 조회 결과가 없으면 사용자 존재도 알 수 없다. 미가입으로 단정하지 않고 거부만 한다. log.info("Badge code not found in catalog, denying: badgeCode={}", badgeCode); return BadgeGrantResult.deny(null, null); } + if (!row.getUserExists()) { + log.info("Badge owner not registered: username={}, badgeCode={}", username, badgeCode); + return BadgeGrantResult.userNotFound(); + } if (row.getGranted()) { log.info("Badge grant allow: username={}, badgeCode={}", username, badgeCode); return BadgeGrantResult.allow(); diff --git a/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java b/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java index afad0a7..e9b3d38 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java +++ b/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java @@ -20,6 +20,7 @@ public class BadgeController { *

* 배지 주인에게 자격이 없으면 잠금 안내 SVG를 200으로 반환한다. * 4xx는 깨진 이미지로 보인다. 대신 결과 값을 요청 속성에 넣어 접근 기록에 남긴다. + * 다만 배지 주인이 가입돼 있지 않으면 테마와 무관하게 404(BADGE_001)로 끊는다. * * @param request 결과 값을 실어 보낼 현재 요청 * @param username 배지를 조회할 GitHub 사용자명 diff --git a/src/main/java/com/tokenphage/api/feature/badge/service/BadgeRenderService.java b/src/main/java/com/tokenphage/api/feature/badge/service/BadgeRenderService.java index 20faa83..969e414 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/service/BadgeRenderService.java +++ b/src/main/java/com/tokenphage/api/feature/badge/service/BadgeRenderService.java @@ -3,8 +3,10 @@ import com.tokenphage.api.domain.badge.BadgeCode; import com.tokenphage.api.domain.badge.service.BadgeGrantResult; import com.tokenphage.api.domain.badge.service.BadgeGrantService; +import com.tokenphage.api.exception.AppException; import com.tokenphage.api.feature.badge.dto.response.BadgeResponse; import com.tokenphage.api.feature.badge.dto.response.BadgeSvgResponse; +import com.tokenphage.api.feature.badge.exception.BadgeErrorCode; import com.tokenphage.api.feature.badge.svg.BadgeMode; import com.tokenphage.api.feature.badge.svg.SvgBuilder; import com.tokenphage.api.feature.badge.svg.theme.locked.LockedBadgeTheme; @@ -33,20 +35,16 @@ public class BadgeRenderService { /** * 사용자 배지 SVG를 반환한다. *

- * theme/mode를 등록된 값으로 정규화한 뒤 캐시 키를 구성한다. 정규화로 캐시 키 카디널리티를 - * 유한하게 제한하여, 임의 theme/mode 입력에 의한 Redis 키 무한 증식을 방지한다. - * 캐시에 값이 있으면 즉시 반환하고, 없으면 DB에서 조회해 SVG를 생성한 뒤 캐시에 저장한다. - * 캐시 TTL은 설정값 badge.cache-ttl-minutes를 따른다. - * - *

- * 자격이 필요한 배지인데 배지 주인에게 자격이 없으면 잠금 안내 SVG를 대신 반환한다. - * 이때 잠금 SVG는 캐시에 저장하지 않으므로 자격을 부여하면 다음 요청에 즉시 반영된다. + * theme/mode를 등록된 값으로 정규화해 임의 입력에 의한 캐시 키 증식을 막는다. + * 캐시 적중이면 즉시 반환하고, 아니면 조회·생성 후 badge.cache-ttl-minutes 동안 캐시한다. + * 자격이 없으면 잠금 SVG를 반환하며, 이 SVG는 캐시하지 않아 부여가 즉시 반영된다. + * 배지 주인이 가입돼 있지 않으면 테마와 무관하게 404로 끊는다. * * @param username 배지를 조회할 GitHub 사용자명 (null 불허) * @param theme 배지 스킨 종류 (미등록 값은 기본 테마로 정규화) * @param mode 색상 모드 (테마가 지원하지 않는 값은 그 테마의 기본 모드로 정규화) * @return SVG 문자열과 자격 판정 결과 - * @throws com.tokenphage.api.exception.AppException 사용자가 없을 경우 (BADGE_001) + * @throws AppException 배지 주인이 가입되지 않았을 경우 (BADGE_001) * @Since 2026-05-27 */ public BadgeSvgResponse getSvg(String username, String theme, String mode) { @@ -56,6 +54,13 @@ public BadgeSvgResponse getSvg(String username, String theme, String mode) { // #0. 자격 확인은 캐시 조회보다 먼저. 캐시-자격 불일치를 막고 회수가 즉시 반영된다. // 잠금 SVG는 캐시하지 않으므로 부여도 즉시 반영된다. BadgeGrantResult grant = badgeGrantService.resolveGrant(username, normalizedTheme); + + // #0-1. 미가입은 자격 거부보다 먼저 끊는다. 공개 테마와 자격 테마의 응답을 404로 통일한다. + if (!grant.userExists()) { + log.info("Badge owner not registered, returning 404: username={}", username); + throw new AppException(BadgeErrorCode.USER_NOT_FOUND); + } + if (!grant.granted()) { log.info("Badge locked, rendering lock notice: username={}, theme={}", username, normalizedTheme); // 잠금 테마 자신의 지원 집합으로 다시 정규화한다 — 원 테마의 악센트 모드를 잠금 테마는 모른다. diff --git a/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java b/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java index 18932a6..108c60e 100644 --- a/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java +++ b/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java @@ -41,9 +41,10 @@ void setUp() { /** * 가짜 조회 결과를 만든다. */ - private BadgeGrantRow row(boolean granted, String displayName, String lockedMessage) { + private BadgeGrantRow row(boolean granted, boolean userExists, String displayName, String lockedMessage) { return new BadgeGrantRow() { @Override public boolean getGranted() { return granted; } + @Override public boolean getUserExists() { return userExists; } @Override public String getDisplayName() { return displayName; } @Override public String getLockedMessage() { return lockedMessage; } }; @@ -58,7 +59,7 @@ class ResolveGrantTest { void 사용가능여부_누구나쓰는배지_허용() { // given given(grantRepo.findGrant(USERNAME, PUBLIC_CODE)) - .willReturn(row(true, "GPU Card", null)); + .willReturn(row(true, true, "GPU Card", null)); // when BadgeGrantResult result = sut.resolveGrant(USERNAME, PUBLIC_CODE); @@ -74,7 +75,7 @@ class ResolveGrantTest { void 사용가능여부_자격필요한배지에자격있음_허용() { // given given(grantRepo.findGrant(USERNAME, PRIVATE_CODE)) - .willReturn(row(true, "Contributor", "PR을 보내고 이 배지를 받아보세요")); + .willReturn(row(true, true, "Contributor", "PR을 보내고 이 배지를 받아보세요")); // when BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE); @@ -88,13 +89,14 @@ class ResolveGrantTest { void 사용가능여부_자격필요한배지에자격없음_거부하고안내문구반환() { // given given(grantRepo.findGrant(USERNAME, PRIVATE_CODE)) - .willReturn(row(false, "Contributor", "PR을 보내고 이 배지를 받아보세요")); + .willReturn(row(false, true, "Contributor", "PR을 보내고 이 배지를 받아보세요")); // when BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE); // then assertThat(result.granted()).isFalse(); + assertThat(result.userExists()).isTrue(); assertThat(result.title()).isEqualTo("Contributor"); assertThat(result.message()).isEqualTo("PR을 보내고 이 배지를 받아보세요"); } @@ -105,7 +107,7 @@ class ResolveGrantTest { // given // 안내 문구 미등록 배지. 기본 문구 대체는 렌더러 담당. given(grantRepo.findGrant(USERNAME, PRIVATE_CODE)) - .willReturn(row(false, "Contributor", null)); + .willReturn(row(false, true, "Contributor", null)); // when BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE); @@ -128,6 +130,8 @@ class ResolveGrantTest { // then assertThat(result.granted()).isFalse(); + // 조회 결과가 없으면 사용자 존재를 알 수 없다. 미가입으로 단정해 404를 내지 않는다. + assertThat(result.userExists()).isTrue(); assertThat(result.title()).isNull(); assertThat(result.message()).isNull(); } @@ -137,7 +141,7 @@ class ResolveGrantTest { void 사용가능여부_호출_받은값이바뀌지않고DB조회로전달됨() { // given given(grantRepo.findGrant(anyString(), anyString())) - .willReturn(row(true, "GPU Card", null)); + .willReturn(row(true, true, "GPU Card", null)); ArgumentCaptor usernameCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor codeCaptor = ArgumentCaptor.forClass(String.class); @@ -150,4 +154,43 @@ class ResolveGrantTest { assertThat(codeCaptor.getValue()).isEqualTo(PUBLIC_CODE); } } + + @Nested + @DisplayName("가입되지 않은 사용자 판정") + class UserNotFoundTest { + + @Test + @DisplayName("사용가능여부_미가입사용자_공개배지여도미가입으로판정") + void 사용가능여부_미가입사용자_공개배지여도미가입으로판정() { + // given + // 공개 배지라 쿼리의 granted는 true지만 users에 행이 없어 userExists가 false다 + given(grantRepo.findGrant(USERNAME, PUBLIC_CODE)) + .willReturn(row(true, false, "GPU Card", null)); + + // when + BadgeGrantResult result = sut.resolveGrant(USERNAME, PUBLIC_CODE); + + // then + assertThat(result.userExists()).isFalse(); + assertThat(result.granted()).isFalse(); + } + + @Test + @DisplayName("사용가능여부_미가입사용자_자격필요배지_잠금문구없이미가입으로판정") + void 사용가능여부_미가입사용자_자격필요배지_잠금문구없이미가입으로판정() { + // given + given(grantRepo.findGrant(USERNAME, PRIVATE_CODE)) + .willReturn(row(false, false, "Contributor", "PR을 보내고 이 배지를 받아보세요")); + + // when + BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE); + + // then + // 미가입이 자격 거부보다 우선한다. 잠금 안내 문구를 싣지 않아 렌더러가 잠금 SVG를 그리지 못한다. + assertThat(result.userExists()).isFalse(); + assertThat(result.granted()).isFalse(); + assertThat(result.title()).isNull(); + assertThat(result.message()).isNull(); + } + } } diff --git a/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java b/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java index 94560c9..b6da80b 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java @@ -9,11 +9,14 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.tokenphage.api.audit.AuditOutcome; import com.tokenphage.api.config.SecurityConfig; +import com.tokenphage.api.exception.AppException; +import com.tokenphage.api.feature.badge.exception.BadgeErrorCode; import com.tokenphage.api.feature.badge.dto.response.BadgeSvgResponse; import com.tokenphage.api.feature.badge.service.BadgeRenderService; import org.junit.jupiter.api.DisplayName; @@ -107,6 +110,22 @@ class ResponseTest { .andExpect(content().string(LOCKED_SVG)); } + @Test + @DisplayName("배지조회_미가입_404와BADGE_001본문반환") + void 배지조회_미가입_404와BADGE_001본문반환() throws Exception { + // given + // 미가입은 자격 거부(200 잠금 SVG)와 달리 상태코드로 구분된다. + given(badgeRenderService.getSvg(USERNAME, "gpu", "light")) + .willThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); + + // when + // then + mockMvc.perform(get("/badge/{username}", USERNAME)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BADGE_001")) + .andExpect(jsonPath("$.message").value("User not found.")); + } + @Test @DisplayName("배지조회_정상_svg타입과no캐시헤더반환") void 배지조회_정상_svg타입과no캐시헤더반환() throws Exception { diff --git a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java index 12582fc..a65351e 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java @@ -19,6 +19,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.test.util.ReflectionTestUtils; @@ -163,6 +164,28 @@ class GrantGate { // 판정을 캐시 뒤로 미루면 자격 회수가 최대 60분간 반영되지 않는다 then(badgeGrantService).should().resolveGrant(USERNAME, THEME); } + + @Test + @DisplayName("배지조회_미가입_USER_NOT_FOUND예외") + void 배지조회_미가입_USER_NOT_FOUND예외() { + // given + given(svgBuilder.normalizeTheme(THEME)).willReturn(THEME); + given(badgeGrantService.resolveGrant(USERNAME, THEME)) + .willReturn(BadgeGrantResult.userNotFound()); + + // when + // then + assertThatThrownBy(() -> service.getSvg(USERNAME, THEME, MODE)) + .isInstanceOf(AppException.class) + .satisfies(ex -> { + assertThat(((AppException) ex).getErrorCode().getCode()).isEqualTo("BADGE_001"); + assertThat(((AppException) ex).getErrorCode().getStatus()).isEqualTo(HttpStatus.NOT_FOUND); + }); + // 미가입은 잠금 SVG 대상이 아니다. 캐시·집계 조회에도 닿지 않는다. + then(lockedBadgeTheme).shouldHaveNoInteractions(); + then(queryService).shouldHaveNoInteractions(); + then(redis).should(never()).opsForValue(); + } } @Nested diff --git a/src/test/java/com/tokenphage/api/integration/UserBadgeGrantRepositoryIntegrationTest.java b/src/test/java/com/tokenphage/api/integration/UserBadgeGrantRepositoryIntegrationTest.java index fbf6496..0cedc58 100644 --- a/src/test/java/com/tokenphage/api/integration/UserBadgeGrantRepositoryIntegrationTest.java +++ b/src/test/java/com/tokenphage/api/integration/UserBadgeGrantRepositoryIntegrationTest.java @@ -155,7 +155,7 @@ class RequireGrant { @Test @DisplayName("SQL 별칭이 BadgeGrantRow getter에 매핑된다") - void 자격조회_projection별칭_displayName과lockedMessage매핑() { + void 자격조회_projection별칭_displayName과lockedMessage와userExists매핑() { // given insertPrivateBadge(); insertUser(GITHUB_ID, USERNAME); @@ -163,10 +163,44 @@ class RequireGrant { // when BadgeGrantRow row = repository.findGrant(USERNAME, PRIVATE_CODE); - // then: alias displayName/lockedMessage 가 getter로 바인딩된다 (불일치 시 null) + // then: alias displayName/lockedMessage/userExists 가 getter로 바인딩된다 (불일치 시 null) assertThat(row).isNotNull(); assertThat(row.getDisplayName()).isEqualTo(DISPLAY_NAME); assertThat(row.getLockedMessage()).isEqualTo(LOCKED_MESSAGE); + assertThat(row.getUserExists()).isTrue(); + } + } + + @Nested + @DisplayName("findGrant() - 사용자 존재 여부") + class UserExistence { + + @Test + @DisplayName("미가입 사용자는 공개 배지여도 userExists가 false다") + void 자격조회_미가입사용자_userExists거짓() { + // given: users에 행을 넣지 않는다 (미가입 상태) + + // when + BadgeGrantRow row = repository.findGrant(USERNAME, PUBLIC_CODE); + + // then: 공개 배지라 granted는 true로 남고, 사용자 존재는 별도 컬럼으로 구분된다 + assertThat(row).isNotNull(); + assertThat(row.getGranted()).isTrue(); + assertThat(row.getUserExists()).isFalse(); + } + + @Test + @DisplayName("가입 사용자는 userExists가 true다") + void 자격조회_가입사용자_userExists참() { + // given + insertUser(GITHUB_ID, USERNAME); + + // when + BadgeGrantRow row = repository.findGrant(USERNAME, PUBLIC_CODE); + + // then: users에 행이 있으면 EXISTS가 참이다 + assertThat(row).isNotNull(); + assertThat(row.getUserExists()).isTrue(); } } } From 193b976638dd41d90883cf5a5053ac0247641530 Mon Sep 17 00:00:00 2001 From: kobenlys Date: Wed, 16 Sep 2026 00:25:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EB=AF=B8=EC=8A=A4=EA=B0=80=20500=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EC=98=AC=EB=B0=94=EB=A5=B8=204xx=EB=A5=BC=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /badge/ 같은 라우팅 미스는 404 NOT_FOUND를, POST /badge/{username} 은 405 METHOD_NOT_ALLOWED를 반환한다. ERROR 스택트레이스도 남지 않는다. 기존에는 GlobalExceptionHandler의 catch-all이 Spring MVC 표준 예외까지 삼켜 전부 500 SERVER_ERROR로 나갔다. /badge/** 가 permitAll이라 미인증 요청만으로 ERROR 로그를 쌓을 수 있었다. ErrorResponse를 구현하는 예외 5종을 전용 핸들러로 잡고, 응답 코드와 메시지를 예외가 지정한 상태에서 파생해 매핑 표 없이 처리한다. --- .../api/exception/GlobalExceptionHandler.java | 37 ++++++ .../exception/GlobalExceptionHandlerTest.java | 38 ++++++ .../GlobalExceptionHandlerWebMvcTest.java | 124 ++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerWebMvcTest.java diff --git a/src/main/java/com/tokenphage/api/exception/GlobalExceptionHandler.java b/src/main/java/com/tokenphage/api/exception/GlobalExceptionHandler.java index 3b4483d..41e4809 100644 --- a/src/main/java/com/tokenphage/api/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/tokenphage/api/exception/GlobalExceptionHandler.java @@ -4,10 +4,17 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import jakarta.servlet.ServletException; + import java.time.format.DateTimeParseException; import java.util.stream.Collectors; @@ -93,6 +100,36 @@ public ResponseEntity handleInvalidArgument(DateTimeParseExceptio .body(new ErrorResponse("INVALID_ARGUMENT", "Request contains an invalid value.")); } + /** + * 라우팅·프로토콜 단계에서 발생한 요청 오류를 예외가 지정한 4xx로 반환한다. + *

+ * 이 예외들은 모두 {@code org.springframework.web.ErrorResponse}를 구현해 상태를 스스로 안다. + * 응답 코드·메시지를 그 상태에서 파생하므로 예외를 추가할 때 매핑 표를 고칠 필요가 없다. + * 예외 메시지는 내부 구현(정적 리소스 폴백 등)을 노출하므로 로그에만 남긴다. + * + * @param e 라우팅·프로토콜 단계 예외 + * @return 예외가 지정한 상태와 그 상태명을 코드로 담은 응답 + * @Since 2026-09-09 + */ + @ExceptionHandler({ + NoResourceFoundException.class, + HttpRequestMethodNotSupportedException.class, + HttpMediaTypeNotSupportedException.class, + HttpMediaTypeNotAcceptableException.class, + MissingServletRequestParameterException.class + }) + public ResponseEntity handleWebRequestException(ServletException e) { + // 목록에 ErrorResponse 미구현 예외가 잘못 추가되면 기존 500 경로로 되돌린다 + if (!(e instanceof org.springframework.web.ErrorResponse errorResponse)) { + return handleException(e); + } + HttpStatus status = HttpStatus.valueOf(errorResponse.getStatusCode().value()); + log.warn("Request could not be handled: status={}, reason={}", status.value(), e.getMessage()); + return ResponseEntity + .status(status) + .body(new ErrorResponse(status.name(), status.getReasonPhrase() + ".")); + } + /** * 예상치 못한 예외를 처리한다. *

diff --git a/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java index 7f46114..1aeb729 100644 --- a/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java @@ -3,12 +3,15 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.time.format.DateTimeParseException; import java.util.List; @@ -76,4 +79,39 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().code()).isEqualTo("INVALID_ARGUMENT"); } + + @Test + @DisplayName("라우팅 미스(NoResourceFoundException) → 404 NOT_FOUND, 예외 메시지 비노출") + void 라우팅미스_404와NOT_FOUND반환() { + // given + NoResourceFoundException ex = new NoResourceFoundException(HttpMethod.GET, "/badge/", "badge"); + + // when + ResponseEntity response = handler.handleWebRequestException(ex); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo("NOT_FOUND"); + assertThat(response.getBody().message()).isEqualTo("Not Found."); + // 예외 메시지("No static resource ...")는 내부 구현이라 응답에 실리지 않는다 + assertThat(response.getBody().message()).doesNotContain("static resource"); + } + + @Test + @DisplayName("메서드 불일치(HttpRequestMethodNotSupportedException) → 405 METHOD_NOT_ALLOWED") + void 메서드불일치_405와METHOD_NOT_ALLOWED반환() { + // given + HttpRequestMethodNotSupportedException ex = + new HttpRequestMethodNotSupportedException("POST", List.of("GET")); + + // when + ResponseEntity response = handler.handleWebRequestException(ex); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo("METHOD_NOT_ALLOWED"); + assertThat(response.getBody().message()).isEqualTo("Method Not Allowed."); + } } diff --git a/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerWebMvcTest.java b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerWebMvcTest.java new file mode 100644 index 0000000..294cb92 --- /dev/null +++ b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerWebMvcTest.java @@ -0,0 +1,124 @@ +package com.tokenphage.api.exception; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.tokenphage.api.config.SecurityConfig; +import com.tokenphage.api.feature.badge.controller.BadgeController; +import com.tokenphage.api.feature.badge.exception.BadgeErrorCode; +import com.tokenphage.api.feature.badge.service.BadgeRenderService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 라우팅·프로토콜 단계의 예외가 올바른 4xx로 나가는지 검증한다. + *

+ * 실제 디스패치를 태워 핸들러 도달 경로를 확인한다. 컨트롤러는 라우팅을 태우기 위한 발판이다. + */ +@WebMvcTest(BadgeController.class) +@Import(SecurityConfig.class) +@TestPropertySource(properties = "badge.jwt-secret=webmvctest-dummy-secret-key-0123456789") +@DisplayName("GlobalExceptionHandler 디스패치 계약") +class GlobalExceptionHandlerWebMvcTest { + + private static final String USERNAME = "octocat"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private BadgeRenderService badgeRenderService; + + @Nested + @DisplayName("라우팅 미스") + class RoutingMiss { + + @Test + @DisplayName("라우팅미스_슬래시로끝나는배지경로_404와NOT_FOUND반환") + void 라우팅미스_슬래시로끝나는배지경로_404와NOT_FOUND반환() throws Exception { + // given + // 어떤 핸들러에도 매칭되지 않아 정적 리소스 폴백으로 흘러간다 + + // when + // then + mockMvc.perform(get("/badge/")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND")) + .andExpect(jsonPath("$.message").value("Not Found.")); + } + + @Test + @DisplayName("라우팅미스_username없는배지경로_404와NOT_FOUND반환") + void 라우팅미스_username없는배지경로_404와NOT_FOUND반환() throws Exception { + // given + + // when + // then + mockMvc.perform(get("/badge")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND")); + } + + @Test + @DisplayName("라우팅미스_username뒤슬래시_404와NOT_FOUND반환") + void 라우팅미스_username뒤슬래시_404와NOT_FOUND반환() throws Exception { + // given + // /badge/{username} 은 매칭되지만 뒤에 슬래시가 붙으면 매칭되지 않는다 + + // when + // then + mockMvc.perform(get("/badge/{username}/", USERNAME)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND")); + } + } + + @Nested + @DisplayName("프로토콜 불일치") + class ProtocolMismatch { + + @Test + @DisplayName("메서드불일치_배지경로에POST_405와METHOD_NOT_ALLOWED반환") + void 메서드불일치_배지경로에POST_405와METHOD_NOT_ALLOWED반환() throws Exception { + // given + // 경로는 매칭되지만 GET만 등록돼 있다 + + // when + // then + mockMvc.perform(post("/badge/{username}", USERNAME)) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.code").value("METHOD_NOT_ALLOWED")); + } + } + + @Nested + @DisplayName("기존 계약 회귀 방지") + class NoRegression { + + @Test + @DisplayName("비즈니스예외_미가입사용자_기존BADGE_001계약유지") + void 비즈니스예외_미가입사용자_기존BADGE_001계약유지() throws Exception { + // given + // 새 핸들러가 handleAppException 을 가리면 안 된다 + given(badgeRenderService.getSvg(USERNAME, "gpu", "light")) + .willThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); + + // when + // then + mockMvc.perform(get("/badge/{username}", USERNAME)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BADGE_001")) + .andExpect(jsonPath("$.message").value("User not found.")); + } + } +} From 33acaf995b4e0fd5af6f8a2e2bdae039b2320037 Mon Sep 17 00:00:00 2001 From: kobenlys Date: Wed, 16 Sep 2026 00:25:42 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=ED=95=98=EB=84=A4=EC=8A=A4=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20.omc/=EB=A5=BC=20gitigno?= =?UTF-8?q?re=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b15868d..0d3f3f1 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ src/test/java/com/tokenphage/api/feature/badge/svg/GpuBadgeLevelsReportTest.java ### 마스코트 export 산출물 — ExportTest가 로컬 재생성. 레포엔 두지 않고 tokenphage-assets로 배포한다 ### assets/ + +### 에이전트 하네스 로컬 상태 — 도구가 생성하며 레포와 무관하다 ### +.omc/