From e9d7554201a2d50ea65bb19d3bc77afcbe8e4782 Mon Sep 17 00:00:00 2001 From: hyoseok Date: Tue, 21 Jul 2026 11:06:27 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B0=9C=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=91=EB=8B=B5=EC=97=90=EC=84=9C=20email=20?= =?UTF-8?q?=EB=93=B1=20PII=20=EC=A0=9C=EA=B1=B0=20(PublicUserResponse=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PublicUserResponse 신설(계약 19필드), userId·githubId·email·role·isNewUser 제외 - UserQueryService.findByUsername·UserController.getUser 반환 타입을 PublicUserResponse로 교체 - UserQueryServiceTest에 리플렉션 기반 PII 미노출·19필드 매핑 검증 추가 --- .../api/domain/user/UserController.java | 5 +- .../domain/user/dto/PublicUserResponse.java | 53 ++++++++++++++++ .../domain/user/service/UserQueryService.java | 6 +- .../user/service/UserQueryServiceTest.java | 60 ++++++++++++++----- 4 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/gitranker/api/domain/user/dto/PublicUserResponse.java diff --git a/src/main/java/com/gitranker/api/domain/user/UserController.java b/src/main/java/com/gitranker/api/domain/user/UserController.java index a3597bd..bae8320 100644 --- a/src/main/java/com/gitranker/api/domain/user/UserController.java +++ b/src/main/java/com/gitranker/api/domain/user/UserController.java @@ -1,5 +1,6 @@ package com.gitranker.api.domain.user; +import com.gitranker.api.domain.user.dto.PublicUserResponse; import com.gitranker.api.domain.user.dto.RegisterUserResponse; import com.gitranker.api.domain.user.service.UserDeletionService; import com.gitranker.api.domain.user.service.UserQueryService; @@ -33,10 +34,10 @@ public class UserController { @GetMapping("/{username}") @Operation(summary = "Get a user's profile", description = "Returns the public Git Ranker profile for a GitHub username.") - public ApiResponse getUser( + public ApiResponse getUser( @PathVariable @Pattern(regexp = USERNAME_PATTERN, message = USERNAME_MESSAGE) String username ) { - RegisterUserResponse response = userQueryService.findByUsername(username); + PublicUserResponse response = userQueryService.findByUsername(username); return ApiResponse.success(response); } diff --git a/src/main/java/com/gitranker/api/domain/user/dto/PublicUserResponse.java b/src/main/java/com/gitranker/api/domain/user/dto/PublicUserResponse.java new file mode 100644 index 0000000..f2f848b --- /dev/null +++ b/src/main/java/com/gitranker/api/domain/user/dto/PublicUserResponse.java @@ -0,0 +1,53 @@ +package com.gitranker.api.domain.user.dto; + +import com.gitranker.api.domain.log.ActivityLog; +import com.gitranker.api.domain.user.Tier; +import com.gitranker.api.domain.user.User; + +import java.time.LocalDateTime; + +public record PublicUserResponse( + String nodeId, + String username, + String profileImage, + LocalDateTime updatedAt, + LocalDateTime lastFullScanAt, + int totalScore, + int ranking, + Tier tier, + double percentile, + int commitCount, + int issueCount, + int prCount, + int mergedPrCount, + int reviewCount, + int diffCommitCount, + int diffIssueCount, + int diffPrCount, + int diffMergedPrCount, + int diffReviewCount +) { + public static PublicUserResponse of(User user, ActivityLog latestLog) { + return new PublicUserResponse( + user.getNodeId(), + user.getUsername(), + user.getProfileImage(), + user.getUpdatedAt(), + user.getLastFullScanAt(), + user.getTotalScore(), + user.getRanking(), + user.getTier(), + user.getPercentile(), + latestLog.getCommitCount(), + latestLog.getIssueCount(), + latestLog.getPrCount(), + latestLog.getMergedPrCount(), + latestLog.getReviewCount(), + latestLog.getDiffCommitCount(), + latestLog.getDiffIssueCount(), + latestLog.getDiffPrCount(), + latestLog.getDiffMergedPrCount(), + latestLog.getDiffReviewCount() + ); + } +} diff --git a/src/main/java/com/gitranker/api/domain/user/service/UserQueryService.java b/src/main/java/com/gitranker/api/domain/user/service/UserQueryService.java index 1896347..1b53eab 100644 --- a/src/main/java/com/gitranker/api/domain/user/service/UserQueryService.java +++ b/src/main/java/com/gitranker/api/domain/user/service/UserQueryService.java @@ -4,7 +4,7 @@ import com.gitranker.api.domain.log.ActivityLogService; import com.gitranker.api.domain.user.User; import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.domain.user.dto.RegisterUserResponse; +import com.gitranker.api.domain.user.dto.PublicUserResponse; import com.gitranker.api.global.error.ErrorType; import com.gitranker.api.global.error.exception.BusinessException; import com.gitranker.api.global.logging.Event; @@ -23,7 +23,7 @@ public class UserQueryService { private final ActivityLogService activityLogService; private final BusinessMetrics businessMetrics; - public RegisterUserResponse findByUsername(String username) { + public PublicUserResponse findByUsername(String username) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new BusinessException(ErrorType.USER_NOT_FOUND)); @@ -35,6 +35,6 @@ public RegisterUserResponse findByUsername(String username) { businessMetrics.incrementProfileViews(); - return RegisterUserResponse.of(user, activityLog, false); + return PublicUserResponse.of(user, activityLog); } } diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserQueryServiceTest.java index 3c95a6c..cf1e5c2 100644 --- a/src/test/java/com/gitranker/api/domain/user/service/UserQueryServiceTest.java +++ b/src/test/java/com/gitranker/api/domain/user/service/UserQueryServiceTest.java @@ -4,7 +4,7 @@ import com.gitranker.api.domain.log.ActivityLogService; import com.gitranker.api.domain.user.User; import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.domain.user.dto.RegisterUserResponse; +import com.gitranker.api.domain.user.dto.PublicUserResponse; import com.gitranker.api.global.error.ErrorType; import com.gitranker.api.global.error.exception.BusinessException; import com.gitranker.api.global.metrics.BusinessMetrics; @@ -15,10 +15,15 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; -import static com.gitranker.api.support.TestFixtures.emptyActivityLog; +import static com.gitranker.api.support.TestFixtures.activityLog; import static com.gitranker.api.support.TestFixtures.savedUser; +import static com.gitranker.api.support.TestFixtures.stats; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; @@ -39,28 +44,55 @@ class UserQueryServiceTest { private BusinessMetrics businessMetrics; @Test - @DisplayName("사용자가 존재하면 최신 활동 로그를 포함한 응답을 반환한다") - void returnsUserProfileWithLatestLog() { + @DisplayName("사용자가 존재하면 최신 활동 로그를 포함한 공개 응답을 반환한다") + void returnsPublicProfileWithLatestLog() { User user = savedUser(1L, "alice"); - user.updateProfile("alice", "https://images.example.com/alice-profile.png", "alice@example.com"); - ActivityLog activityLog = emptyActivityLog(user); + // 카운트마다 서로 다른 값을 주어 필드 매핑이 뒤섞이지 않았는지까지 검증한다. + ActivityLog activityLog = activityLog(user, stats(11, 12, 13, 14, 15), stats(1, 2, 3, 4, 5)); when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); when(activityLogService.getLatestLog(user)).thenReturn(activityLog); - RegisterUserResponse response = userQueryService.findByUsername("alice"); + PublicUserResponse response = userQueryService.findByUsername("alice"); + assertThat(response.nodeId()).isEqualTo("node-alice"); assertThat(response.username()).isEqualTo("alice"); - assertThat(response.userId()).isEqualTo(1L); - assertThat(response.email()).isEqualTo("alice@example.com"); - assertThat(response.profileImage()).isEqualTo("https://images.example.com/alice-profile.png"); - assertThat(response.role()).isEqualTo(user.getRole()); - assertThat(response.commitCount()).isEqualTo(activityLog.getCommitCount()); - assertThat(response.diffCommitCount()).isEqualTo(activityLog.getDiffCommitCount()); - assertThat(response.isNewUser()).isFalse(); + assertThat(response.profileImage()).isEqualTo("https://images.example.com/alice.png"); + assertThat(response.updatedAt()).isEqualTo(user.getUpdatedAt()); + assertThat(response.lastFullScanAt()).isEqualTo(user.getLastFullScanAt()); + assertThat(response.totalScore()).isEqualTo(user.getTotalScore()); + assertThat(response.ranking()).isEqualTo(user.getRanking()); + assertThat(response.tier()).isEqualTo(user.getTier()); + assertThat(response.percentile()).isEqualTo(user.getPercentile()); + assertThat(response.commitCount()).isEqualTo(11); + assertThat(response.issueCount()).isEqualTo(12); + assertThat(response.prCount()).isEqualTo(13); + assertThat(response.mergedPrCount()).isEqualTo(14); + assertThat(response.reviewCount()).isEqualTo(15); + assertThat(response.diffCommitCount()).isEqualTo(1); + assertThat(response.diffIssueCount()).isEqualTo(2); + assertThat(response.diffPrCount()).isEqualTo(3); + assertThat(response.diffMergedPrCount()).isEqualTo(4); + assertThat(response.diffReviewCount()).isEqualTo(5); verify(businessMetrics).incrementProfileViews(); } + @Test + @DisplayName("공개 응답 DTO에는 PII·내부 식별자 필드가 없고 계약상 19개 공개 필드만 포함한다") + void publicResponseExcludesPii() { + Set componentNames = Arrays.stream(PublicUserResponse.class.getRecordComponents()) + .map(RecordComponent::getName) + .collect(Collectors.toSet()); + + assertThat(componentNames).doesNotContain("userId", "githubId", "email", "role", "isNewUser"); + assertThat(componentNames).containsExactlyInAnyOrder( + "nodeId", "username", "profileImage", "updatedAt", "lastFullScanAt", + "totalScore", "ranking", "tier", "percentile", + "commitCount", "issueCount", "prCount", "mergedPrCount", "reviewCount", + "diffCommitCount", "diffIssueCount", "diffPrCount", "diffMergedPrCount", "diffReviewCount" + ); + } + @Test @DisplayName("사용자가 없으면 USER_NOT_FOUND 예외가 발생한다") void throwsWhenUserDoesNotExist() {