From fc2f0fe8470d19a0f1b973481f7612a5a0374eab Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 24 Aug 2026 12:20:37 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EC=9D=B4=EB=AF=B8=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=ED=95=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98=20Git?= =?UTF-8?q?Hub=20=ED=86=A0=ED=81=B0=20=EB=B0=B1=ED=95=84=20=ED=8C=8C?= =?UTF-8?q?=EA=B8=B0=20+=20=EC=9C=84=EC=9E=84=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #198 은 탈퇴 시 GitHub 토큰을 폐기하도록 했지만 **그 시점에 이미 탈퇴해 있던** 사용자들은 다루지 않았다. V28 은 CHECK 제약을 완화하기만 했고 백필이 없다. 그 사람들은 앞으로 User.withdraw() 가 불릴 일도 없어서, 백필하지 않으면 repo 스코프 토큰이 영원히 남는다 — #198 이 없애려던 상태 그 자체다. V31 로 is_deleted=TRUE 인 행의 토큰을 NULL 로 정리한다. 함께: InternalGithubTokenService 가 findById 를 쓰고 있어 삭제 여부를 보지 않았다. #198 이후로는 토큰이 비어 hasGithubLink 에서 걸리지만, 그건 "값이 비어 있어서" 막히는 데이터 상태 의존 방어다. findByIdAndDeletedFalse 로 바꿔 상태와 무관하게 탈퇴 계정을 먼저 막는다. 이 둘은 함께 있을 때 의미가 있다 — 백필 전 데이터의 위임 경로가 바로 이 조회였다. 이 서비스에 테스트가 없어 3건 추가(정상 위임 / 탈퇴 계정 거부 / Google 전용 계정). --- .../InternalGithubTokenService.java | 5 +- ...purge_github_tokens_of_withdrawn_users.sql | 10 +++ .../InternalGithubTokenServiceTest.java | 62 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/resources/db/migration/V31__purge_github_tokens_of_withdrawn_users.sql create mode 100644 backend/src/test/java/com/stackup/stackup/github/application/InternalGithubTokenServiceTest.java 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); + } +}