diff --git a/backend/src/main/java/com/stackup/stackup/github/application/InternalGithubTokenService.java b/backend/src/main/java/com/stackup/stackup/github/application/InternalGithubTokenService.java index 6e4d1cd7..ef6bb876 100644 --- a/backend/src/main/java/com/stackup/stackup/github/application/InternalGithubTokenService.java +++ b/backend/src/main/java/com/stackup/stackup/github/application/InternalGithubTokenService.java @@ -18,7 +18,10 @@ public class InternalGithubTokenService { private final GithubTokenCipher tokenCipher; public String fetchPlainAccessToken(Long userId) { - User user = userRepository.findById(userId) + // 탈퇴한 계정의 토큰은 위임하지 않는다. 탈퇴 시 토큰을 지우므로(User.withdraw) 보통은 + // hasGithubLink 에서 걸리지만, 그건 "값이 비어 있어서" 막히는 것이라 데이터 상태에 + // 기대는 방어다. 삭제 여부로 먼저 막아 상태와 무관하게 닫는다. + User user = userRepository.findByIdAndDeletedFalse(userId) .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); // Google 로 가입한 계정은 GitHub 토큰이 없다. 그대로 복호화로 넘기면 NPE 가 500 으로 // 새어나가므로, 무엇이 부족한지 말해 주는 도메인 에러로 바꾼다. diff --git a/backend/src/main/resources/db/migration/V31__purge_github_tokens_of_withdrawn_users.sql b/backend/src/main/resources/db/migration/V31__purge_github_tokens_of_withdrawn_users.sql new file mode 100644 index 00000000..840ed53a --- /dev/null +++ b/backend/src/main/resources/db/migration/V31__purge_github_tokens_of_withdrawn_users.sql @@ -0,0 +1,10 @@ +-- V28 은 탈퇴 시 GitHub 토큰을 비울 수 있도록 CHECK 제약을 완화하기만 했다. 그 시점에 +-- **이미 탈퇴해 있던** 사용자들의 토큰은 그대로 남아 있다 — #198 은 이후 탈퇴만 처리한다. +-- +-- 그런데 "떠난 사용자의 repo 스코프 자격증명을 무기한 보관하지 않는다"는 목적에서 보면 +-- 그 사람들이 바로 그 대상이다. 이미 탈퇴했으니 앞으로 User.withdraw() 가 불릴 일도 없어 +-- 백필하지 않으면 영원히 남는다. +UPDATE users +SET encrypted_github_access_token = NULL +WHERE is_deleted = TRUE + AND encrypted_github_access_token IS NOT NULL; diff --git a/backend/src/test/java/com/stackup/stackup/github/application/InternalGithubTokenServiceTest.java b/backend/src/test/java/com/stackup/stackup/github/application/InternalGithubTokenServiceTest.java new file mode 100644 index 00000000..c94674e0 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/github/application/InternalGithubTokenServiceTest.java @@ -0,0 +1,62 @@ +package com.stackup.stackup.github.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.github.infrastructure.GithubTokenCipher; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * AI 서버가 레포 분석 시점에 위임받는 GitHub access token 은 `repo` 스코프다 — + * 비공개 레포까지 읽을 수 있는 살아있는 자격증명이라 위임 조건이 좁아야 한다. + */ +@ExtendWith(MockitoExtension.class) +class InternalGithubTokenServiceTest { + + @Mock UserRepository userRepository; + @Mock GithubTokenCipher tokenCipher; + @InjectMocks InternalGithubTokenService service; + + @Test + void returnsDecryptedTokenForActiveUser() { + User user = User.createGithubUser(1L, "u", null, null, "enc"); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(tokenCipher.decrypt("enc")).thenReturn("gho_plain"); + + assertThat(service.fetchPlainAccessToken(1L)).isEqualTo("gho_plain"); + } + + // 탈퇴한 계정은 삭제 여부에서 먼저 막는다. 토큰이 비어 있어서 막히는 것에만 기대면 + // 백필 전 데이터·향후 실수에 그대로 뚫린다. + @Test + void refusesWithdrawnUserEvenIfTokenRowStillPresent() { + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.fetchPlainAccessToken(1L)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getErrorCode()) + .isEqualTo(ApiErrorCode.USER_NOT_FOUND); + } + + // Google 로 가입한 계정은 GitHub 토큰이 없다 — NPE 가 500 으로 새지 않게 도메인 에러로. + @Test + void refusesGoogleOnlyAccountWithDomainError() { + User google = User.createGoogleUser("g-1", "u", null, null); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(google)); + + assertThatThrownBy(() -> service.fetchPlainAccessToken(1L)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getErrorCode()) + .isEqualTo(ApiErrorCode.AUTH_GITHUB_NOT_LINKED); + } +}