diff --git a/src/main/java/com/example/dvely/auth/application/command/AuthCommandService.java b/src/main/java/com/example/dvely/auth/application/command/AuthCommandService.java index 600929e..095013d 100644 --- a/src/main/java/com/example/dvely/auth/application/command/AuthCommandService.java +++ b/src/main/java/com/example/dvely/auth/application/command/AuthCommandService.java @@ -42,7 +42,7 @@ public class AuthCommandService { private final TokenBlacklistPort tokenBlacklistPort; private final JwtProperties jwtProperties; private final OAuthStateManager oAuthStateManager; - private final GithubTokenCleaner githubTokenCleaner; + private final GithubUserTokenRefresher tokenRefresher; /** * GitHub OAuth 로그인 @@ -171,42 +171,24 @@ public void linkGithubAppByCode(Long installationId, String code) { */ @Transactional public String refreshGithubUserToken(Long userId) { - // 같은 요청 안에서 다른 경로가 이미 갱신했을 수 있다. 그 갱신은 REQUIRES_NEW 로 커밋되지만 - // 호출자의 영속성 컨텍스트에는 옛 UserEntity 가 남아 여전히 만료로 보이므로, 커밋된 최신 - // 상태를 따로 읽어 먼저 확인한다. 이 확인이 없으면 두 번째 호출이 이미 회전돼 무효가 된 - // 리프레시 토큰을 들고 GitHub 에 가서 bad_refresh_token 을 맞고, 아래 catch 가 사용자의 - // GitHub 연동을 통째로 지운다. + // 빠른 경로 — 다른 흐름이 이미 갱신했으면 잠금까지 가지 않는다. 그 갱신은 별도 + // 트랜잭션으로 커밋되지만 호출자의 영속성 컨텍스트에는 옛 UserEntity 가 남아 여전히 + // 만료로 보이므로, 커밋된 최신 상태를 따로 읽어 확인한다. // + // 이 확인이 없으면 두 번째 호출이 이미 회전돼 무효가 된 리프레시 토큰을 들고 GitHub 에 + // 가서 bad_refresh_token 을 맞고, 그 catch 가 사용자의 GitHub 연동을 통째로 지운다. // 실제로 그렇게 날아갔다(2026-08-18 운영). 프로젝트 개요 조회 하나가 GitHub 을 두 번 - // 호출하는데(최근 커밋 · 저장소 상태) 둘 다 이 경로를 타서, 화면을 여는 것만으로 연동이 - // 끊기고 응답은 500 이 됐다. - Optional alreadyRefreshed = githubTokenCleaner.readValidAccessToken(userId); + // 호출하는데(최근 커밋 · 저장소 상태) 둘 다 이 경로를 탔다. + Optional alreadyRefreshed = tokenRefresher.readValidAccessToken(userId); if (alreadyRefreshed.isPresent()) { log.info("GitHub App User Token 갱신 생략 — 이미 갱신됨: userId={}", userId); return alreadyRefreshed.get(); } - User user = userRepository.findById(userId) - .orElseThrow(() -> new NotFoundException("유저를 찾을 수 없습니다: " + userId)); - - if (user.getGithubUserRefreshToken() == null) { - throw new ForbiddenException("GitHub App이 연동되지 않았습니다. GitHub App을 다시 설치해 주세요."); - } - - try { - GithubAppPort.GithubUserTokenInfo tokenInfo = githubAppPort.refreshUserToken(user.getGithubUserRefreshToken()); - LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(tokenInfo.expiresInSeconds()); - githubTokenCleaner.saveAndCommit(userId, tokenInfo.accessToken(), tokenInfo.refreshToken(), expiresAt); - log.info("GitHub App User Token 갱신 완료: userId={}", userId); - return tokenInfo.accessToken(); - } catch (IllegalStateException e) { - if (e.getMessage() != null && e.getMessage().contains("bad_refresh_token")) { - // REQUIRES_NEW 트랜잭션으로 커밋 — 현재 트랜잭션이 롤백되어도 클리어는 유지됨 - githubTokenCleaner.clearAndCommit(userId); - throw new ForbiddenException("GitHub App 연동이 만료되었습니다. GitHub App을 다시 설치해 주세요."); - } - throw e; - } + // 느린 경로 — 유저 행을 잠그고 갱신한다. 위 확인과 실제 갱신 사이에는 틈이 있어서, + // 동시에 들어온 두 흐름이 둘 다 여기까지 올 수 있다. 잠금이 없으면 그 둘이 같은 + // 리프레시 토큰을 들고 GitHub 에 가고, 뒤에 도착한 쪽이 연동을 지운다(#162). + return tokenRefresher.refreshWithLock(userId); } private String issueRefreshToken(Long userId) { diff --git a/src/main/java/com/example/dvely/auth/application/command/GithubTokenCleaner.java b/src/main/java/com/example/dvely/auth/application/command/GithubTokenCleaner.java deleted file mode 100644 index ca5be78..0000000 --- a/src/main/java/com/example/dvely/auth/application/command/GithubTokenCleaner.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.example.dvely.auth.application.command; - -import com.example.dvely.auth.domain.model.User; -import com.example.dvely.auth.domain.repository.UserRepository; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -@Slf4j -@Component -@RequiredArgsConstructor -public class GithubTokenCleaner { - - private final UserRepository userRepository; - - // 외부 트랜잭션이 롤백되더라도 토큰 갱신은 반드시 커밋되어야 하므로 REQUIRES_NEW - // GitHub는 이미 old refresh_token을 무효화했으므로 롤백되면 다음 갱신 시 bad_refresh_token 발생 - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void saveAndCommit(Long userId, String accessToken, String refreshToken, LocalDateTime expiresAt) { - userRepository.findById(userId).ifPresent(user -> { - user.updateUserToken(accessToken, refreshToken, expiresAt); - userRepository.save(user); - log.info("GitHub App 토큰 갱신 커밋 완료: userId={}", userId); - }); - } - - /** - * 커밋된 최신 액세스 토큰을 읽는다. 아직 만료되지 않았을 때만 값이 있다. - * - *

REQUIRES_NEW 인 것이 핵심이다. 호출자의 영속성 컨텍스트에는 옛 UserEntity 가 남아 - * 있어서 그냥 findById 하면 {@link #saveAndCommit} 이 이미 커밋한 새 토큰이 보이지 않고 - * 여전히 만료로 판정된다. 새 트랜잭션은 새 영속성 컨텍스트를 쓰므로 커밋된 값을 본다.

- */ - @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) - public Optional readValidAccessToken(Long userId) { - return userRepository.findById(userId) - .filter(user -> !user.isUserAccessTokenExpired()) - .map(User::getGithubUserAccessToken) - .filter(token -> token != null && !token.isBlank()); - } - - // 외부 트랜잭션이 롤백되더라도 토큰 클리어는 반드시 커밋되어야 하므로 REQUIRES_NEW - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void clearAndCommit(Long userId) { - userRepository.findById(userId).ifPresent(user -> { - user.clearGithubAppToken(); - userRepository.save(user); - log.warn("GitHub App 토큰 초기화 커밋 완료: userId={}", userId); - }); - } -} diff --git a/src/main/java/com/example/dvely/auth/application/command/GithubUserTokenRefresher.java b/src/main/java/com/example/dvely/auth/application/command/GithubUserTokenRefresher.java new file mode 100644 index 0000000..f07889f --- /dev/null +++ b/src/main/java/com/example/dvely/auth/application/command/GithubUserTokenRefresher.java @@ -0,0 +1,102 @@ +package com.example.dvely.auth.application.command; + +import com.example.dvely.auth.application.port.out.GithubAppPort; +import com.example.dvely.auth.domain.model.User; +import com.example.dvely.auth.domain.repository.UserRepository; +import com.example.dvely.common.exception.ForbiddenException; +import com.example.dvely.common.exception.NotFoundException; +import java.time.LocalDateTime; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * GitHub App User Token 갱신을 소유한다. + * + * GitHub 은 리프레시 토큰을 쓸 때마다 회전시킨다. 그래서 같은 유저의 갱신이 겹치면 뒤에 도착한 + * 쪽이 이미 무효가 된 값을 들고 가 bad_refresh_token 을 맞고, 그 catch 가 연동을 통째로 지운다 — + * 앞선 흐름이 방금 받아온 멀쩡한 토큰까지 함께 사라진다. 사용자는 아무 잘못 없이 재인증을 해야 + * 한다. 겹치는 조합은 드물지 않다. 사용자 요청뿐 아니라 도메인 검증·배포 회수 워커도 같은 경로를 + * 타고, 그것들은 1분마다 돈다. + * + * 그래서 갱신 구간 전체를 유저 행 잠금으로 직렬화한다. 뒤에 온 쪽은 앞이 끝날 때까지 기다렸다가 + * 잠금을 얻은 뒤 상태를 다시 보고, 이미 갱신돼 있으면 GitHub 을 부르지 않고 그 토큰을 그대로 + * 쓴다. + * + * 잠금은 저장과 같은 트랜잭션에 있어야 한다. 바깥에서 잠그고 저장만 REQUIRES_NEW 로 떼면, 안쪽 + * 트랜잭션이 바깥이 쥔 잠금을 기다리다 교착된다. 그래서 이 메서드 하나가 잠금·조회·GitHub 호출· + * 저장을 모두 REQUIRES_NEW 안에서 끝낸다. 호출한 쪽이 롤백돼도 갱신은 남아야 하기 때문이기도 + * 하다 — GitHub 은 이미 옛 리프레시 토큰을 버렸다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GithubUserTokenRefresher { + + private final UserRepository userRepository; + private final GithubAppPort githubAppPort; + + /** + * 커밋된 최신 액세스 토큰을 잠금 없이 읽는다. 아직 만료되지 않았을 때만 값이 있다. + * + * 호출자의 영속성 컨텍스트에는 옛 UserEntity 가 남아 있을 수 있어 그냥 findById 하면 이미 + * 커밋된 새 토큰이 안 보인다. 새 트랜잭션은 새 영속성 컨텍스트를 쓰므로 커밋된 값을 본다. + * 잠금을 잡기 전의 빠른 경로다 — 대부분의 호출은 여기서 끝나고 DB 잠금까지 가지 않는다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + public Optional readValidAccessToken(Long userId) { + return userRepository.findById(userId) + .filter(user -> !user.isUserAccessTokenExpired()) + .map(User::getGithubUserAccessToken) + .filter(token -> token != null && !token.isBlank()); + } + + /** + * 유저 행을 잠근 채 갱신한다. + * + * ForbiddenException 은 롤백 대상에서 뺀다. bad_refresh_token 을 맞았을 때 토큰을 지운 뒤 + * 예외를 던지는데, 롤백되면 그 지움이 사라져 다음 호출이 같은 무효 토큰으로 또 GitHub 에 + * 간다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = ForbiddenException.class) + public String refreshWithLock(Long userId) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new NotFoundException("유저를 찾을 수 없습니다: " + userId)); + + // 잠금을 기다리는 동안 앞선 흐름이 갱신을 마쳤을 수 있다. 그 경우 GitHub 을 부르면 + // 이미 회전된 리프레시 토큰을 쓰게 되므로, 여기서 멈추는 것이 이 잠금의 핵심이다. + String current = user.getGithubUserAccessToken(); + if (!user.isUserAccessTokenExpired() && current != null && !current.isBlank()) { + log.info("GitHub App User Token 갱신 생략 — 잠금 획득 후 이미 갱신됨: userId={}", userId); + return current; + } + + if (user.getGithubUserRefreshToken() == null) { + throw new ForbiddenException("GitHub App이 연동되지 않았습니다. GitHub App을 다시 설치해 주세요."); + } + + try { + GithubAppPort.GithubUserTokenInfo tokenInfo = + githubAppPort.refreshUserToken(user.getGithubUserRefreshToken()); + user.updateUserToken( + tokenInfo.accessToken(), + tokenInfo.refreshToken(), + LocalDateTime.now().plusSeconds(tokenInfo.expiresInSeconds()) + ); + userRepository.save(user); + log.info("GitHub App User Token 갱신 완료: userId={}", userId); + return tokenInfo.accessToken(); + } catch (IllegalStateException exception) { + if (exception.getMessage() != null && exception.getMessage().contains("bad_refresh_token")) { + user.clearGithubAppToken(); + userRepository.save(user); + log.warn("GitHub App 토큰 초기화 — 리프레시 토큰이 거부됨: userId={}", userId); + throw new ForbiddenException("GitHub App 연동이 만료되었습니다. GitHub App을 다시 설치해 주세요."); + } + throw exception; + } + } +} diff --git a/src/main/java/com/example/dvely/auth/domain/repository/UserRepository.java b/src/main/java/com/example/dvely/auth/domain/repository/UserRepository.java index 7abaab5..a0b6951 100644 --- a/src/main/java/com/example/dvely/auth/domain/repository/UserRepository.java +++ b/src/main/java/com/example/dvely/auth/domain/repository/UserRepository.java @@ -8,6 +8,13 @@ public interface UserRepository { Optional findByGithubId(GithubId githubId); Optional findById(Long id); + + /** + * 유저 행을 잠근 채 읽는다. GitHub 토큰 갱신처럼 "읽고 → 외부에 물어보고 → 쓰는" 구간을 + * 직렬화할 때 쓴다. 잠금은 호출한 트랜잭션이 끝날 때까지 유지되므로, 그 트랜잭션 안에서 + * 저장까지 마쳐야 한다. + */ + Optional findByIdForUpdate(Long id); Optional findByGithubInstallationId(Long githubInstallationId); User save(User user); } diff --git a/src/main/java/com/example/dvely/auth/infrastructure/persistence/repository/SpringDataUserRepository.java b/src/main/java/com/example/dvely/auth/infrastructure/persistence/repository/SpringDataUserRepository.java index f6468a3..8fc6ea7 100644 --- a/src/main/java/com/example/dvely/auth/infrastructure/persistence/repository/SpringDataUserRepository.java +++ b/src/main/java/com/example/dvely/auth/infrastructure/persistence/repository/SpringDataUserRepository.java @@ -1,11 +1,20 @@ package com.example.dvely.auth.infrastructure.persistence.repository; import com.example.dvely.auth.infrastructure.persistence.entity.UserEntity; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface SpringDataUserRepository extends JpaRepository { Optional findByGithubId(String githubId); Optional findByGithubInstallationId(Long githubInstallationId); + + // SELECT ... FOR UPDATE. 같은 유저의 토큰 갱신이 겹치면 뒤에 온 쪽이 여기서 기다린다. + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from UserEntity u where u.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/main/java/com/example/dvely/auth/infrastructure/persistence/repository/UserRepositoryAdapter.java b/src/main/java/com/example/dvely/auth/infrastructure/persistence/repository/UserRepositoryAdapter.java index 8806b40..e1769c5 100644 --- a/src/main/java/com/example/dvely/auth/infrastructure/persistence/repository/UserRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/auth/infrastructure/persistence/repository/UserRepositoryAdapter.java @@ -27,6 +27,12 @@ public Optional findById(Long id) { .map(UserEntity::toDomain); } + @Override + public Optional findByIdForUpdate(Long id) { + return springDataUserRepository.findByIdForUpdate(id) + .map(UserEntity::toDomain); + } + @Override public Optional findByGithubInstallationId(Long githubInstallationId) { return springDataUserRepository.findByGithubInstallationId(githubInstallationId) diff --git a/src/test/java/com/example/dvely/auth/application/command/AuthCommandServiceTest.java b/src/test/java/com/example/dvely/auth/application/command/AuthCommandServiceTest.java index 94f2fa5..e224f72 100644 --- a/src/test/java/com/example/dvely/auth/application/command/AuthCommandServiceTest.java +++ b/src/test/java/com/example/dvely/auth/application/command/AuthCommandServiceTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -43,7 +42,7 @@ class AuthCommandServiceTest { @Mock private TokenPort tokenPort; @Mock private TokenBlacklistPort tokenBlacklistPort; @Mock private OAuthStateManager oAuthStateManager; - @Mock private GithubTokenCleaner githubTokenCleaner; + @Mock private GithubUserTokenRefresher tokenRefresher; private AuthCommandService service; @@ -60,7 +59,7 @@ void setUp() { tokenBlacklistPort, new JwtProperties("test-secret", 3_600_000L, 2_592_000_000L), oAuthStateManager, - githubTokenCleaner + tokenRefresher ); } @@ -120,37 +119,32 @@ void unknownOwnerCannotLinkGithubAppOrCallExternalPort() { verify(userRepository, never()).save(any()); } - // ── 한 요청에서 갱신이 두 번 일어나는 경우 ──────────────────────────────────────── + // ── 갱신이 겹치는 경우 ────────────────────────────────────────────────────────── @Test - void aSecondRefreshInTheSameRequestReusesTheTokenTheFirstOneCommitted() { - // 프로젝트 개요 조회 하나가 GitHub 을 두 번 호출한다(최근 커밋 · 저장소 상태). 두 번째 + void aSecondRefreshReusesTheTokenTheFirstOneCommitted() { + // 프로젝트 개요 조회 하나가 GitHub 을 두 번 호출한다(최근 커밋 · 저장소 상태). 뒤에 오는 // 호출은 바깥 영속성 컨텍스트의 옛 UserEntity 를 읽어 여전히 만료로 판정하는데, 그대로 // 갱신하면 이미 회전된 리프레시 토큰을 들고 가 bad_refresh_token 을 맞는다. - when(githubTokenCleaner.readValidAccessToken(10L)).thenReturn(Optional.of("fresh-access")); + when(tokenRefresher.readValidAccessToken(10L)).thenReturn(Optional.of("fresh-access")); assertThat(service.refreshGithubUserToken(10L)).isEqualTo("fresh-access"); - // GitHub 을 다시 부르지 않는다 — 부르는 순간 리프레시 토큰이 또 회전한다. + // 잠금까지 가지 않는다 — 대부분의 호출이 여기서 끝나야 DB 잠금 경합이 생기지 않는다. + verify(tokenRefresher, never()).refreshWithLock(any()); verify(githubAppPort, never()).refreshUserToken(any()); - // 그리고 무엇보다 사용자의 GitHub 연동을 지우지 않는다. - verify(githubTokenCleaner, never()).clearAndCommit(any()); - verify(githubTokenCleaner, never()).saveAndCommit(any(), any(), any(), any()); } @Test - void refreshStillHappensWhenNoOneElseHasRefreshedYet() { - when(githubTokenCleaner.readValidAccessToken(10L)).thenReturn(Optional.empty()); - User user = user(10L, 55L); - user.updateUserToken("old-access", "old-refresh", LocalDateTime.now().minusMinutes(1)); - when(userRepository.findById(10L)).thenReturn(Optional.of(user)); - when(githubAppPort.refreshUserToken("old-refresh")) - .thenReturn(new GithubAppPort.GithubUserTokenInfo("new-access", "new-refresh", 28800L, 15811200L)); + void aRefreshThatNoOneElseHandledGoesThroughTheLock() { + // 빠른 경로에서 못 찾으면 잠금 경로로 넘긴다. 동시에 들어온 두 흐름이 둘 다 여기까지 + // 올 수 있고, 잠금이 없으면 그 둘이 같은 리프레시 토큰을 들고 GitHub 에 간다(#162). + when(tokenRefresher.readValidAccessToken(10L)).thenReturn(Optional.empty()); + when(tokenRefresher.refreshWithLock(10L)).thenReturn("new-access"); assertThat(service.refreshGithubUserToken(10L)).isEqualTo("new-access"); - verify(githubTokenCleaner).saveAndCommit(eq(10L), eq("new-access"), eq("new-refresh"), any()); - verify(githubTokenCleaner, never()).clearAndCommit(any()); + verify(tokenRefresher).refreshWithLock(10L); } private User user(Long id, Long installationId) { diff --git a/src/test/java/com/example/dvely/auth/application/command/GithubUserTokenRefresherTest.java b/src/test/java/com/example/dvely/auth/application/command/GithubUserTokenRefresherTest.java new file mode 100644 index 0000000..071bced --- /dev/null +++ b/src/test/java/com/example/dvely/auth/application/command/GithubUserTokenRefresherTest.java @@ -0,0 +1,138 @@ +package com.example.dvely.auth.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.dvely.auth.application.port.out.GithubAppPort; +import com.example.dvely.auth.domain.model.User; +import com.example.dvely.auth.domain.repository.UserRepository; +import com.example.dvely.auth.domain.value.GithubId; +import com.example.dvely.common.exception.ForbiddenException; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GithubUserTokenRefresherTest { + + private UserRepository userRepository; + private GithubAppPort githubAppPort; + private GithubUserTokenRefresher refresher; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + githubAppPort = mock(GithubAppPort.class); + refresher = new GithubUserTokenRefresher(userRepository, githubAppPort); + } + + @Test + void aFlowThatWaitedForTheLockUsesTheTokenTheWinnerCommitted() { + // 이 클래스가 존재하는 이유다. 잠금을 기다리는 동안 앞선 흐름이 갱신을 마쳤다면, + // 여기서 GitHub 을 부르는 순간 이미 회전된 리프레시 토큰을 쓰게 되고 bad_refresh_token + // 을 맞아 사용자의 연동이 통째로 지워진다. + when(userRepository.findByIdForUpdate(10L)) + .thenReturn(Optional.of(userWithToken("fresh-access", "refresh", LocalDateTime.now().plusHours(7)))); + + assertThat(refresher.refreshWithLock(10L)).isEqualTo("fresh-access"); + + verify(githubAppPort, never()).refreshUserToken(any()); + verify(userRepository, never()).save(any()); + } + + @Test + void theFirstFlowActuallyRefreshesAndStoresTheRotatedPair() { + User user = userWithToken("old-access", "old-refresh", LocalDateTime.now().minusMinutes(1)); + when(userRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(user)); + when(githubAppPort.refreshUserToken("old-refresh")) + .thenReturn(new GithubAppPort.GithubUserTokenInfo("new-access", "new-refresh", 28800L, 15811200L)); + + assertThat(refresher.refreshWithLock(10L)).isEqualTo("new-access"); + + assertThat(user.getGithubUserAccessToken()).isEqualTo("new-access"); + assertThat(user.getGithubUserRefreshToken()).isEqualTo("new-refresh"); + verify(userRepository).save(user); + } + + @Test + void anExpiredAccessTokenWithoutARefreshTokenIsReportedAsNotLinked() { + when(userRepository.findByIdForUpdate(10L)) + .thenReturn(Optional.of(userWithToken(null, null, null))); + + assertThatThrownBy(() -> refresher.refreshWithLock(10L)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("연동되지 않았습니다"); + + verify(githubAppPort, never()).refreshUserToken(any()); + } + + @Test + void aRejectedRefreshTokenClearsTheLinkage() { + // 잠금 안에서 지운다. 예외를 던지지만 ForbiddenException 은 롤백 대상이 아니라 + // 이 지움은 커밋된다 — 롤백되면 다음 호출이 같은 무효 토큰으로 또 GitHub 에 간다. + User user = userWithToken("old-access", "bad-refresh", LocalDateTime.now().minusMinutes(1)); + when(userRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(user)); + when(githubAppPort.refreshUserToken("bad-refresh")) + .thenThrow(new IllegalStateException("bad_refresh_token")); + + assertThatThrownBy(() -> refresher.refreshWithLock(10L)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("만료되었습니다"); + + assertThat(user.getGithubUserAccessToken()).isNull(); + assertThat(user.getGithubUserRefreshToken()).isNull(); + verify(userRepository).save(user); + } + + @Test + void anUnrelatedFailureDoesNotClearTheLinkage() { + // GitHub 이 잠깐 5xx 를 내는 것과 리프레시 토큰이 거부된 것은 다르다. 전자로 연동을 + // 지우면 멀쩡한 사용자가 재인증을 하게 된다. + User user = userWithToken("old-access", "good-refresh", LocalDateTime.now().minusMinutes(1)); + when(userRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(user)); + when(githubAppPort.refreshUserToken("good-refresh")) + .thenThrow(new IllegalStateException("502 Bad Gateway")); + + assertThatThrownBy(() -> refresher.refreshWithLock(10L)) + .isInstanceOf(IllegalStateException.class); + + assertThat(user.getGithubUserRefreshToken()).isEqualTo("good-refresh"); + verify(userRepository, never()).save(any()); + } + + @Test + void theFastPathReadsWithoutTakingTheLock() { + when(userRepository.findById(10L)) + .thenReturn(Optional.of(userWithToken("fresh-access", "refresh", LocalDateTime.now().plusHours(7)))); + + assertThat(refresher.readValidAccessToken(10L)).contains("fresh-access"); + + verify(userRepository, never()).findByIdForUpdate(any()); + } + + @Test + void theFastPathReportsNothingWhenTheTokenIsExpired() { + when(userRepository.findById(10L)) + .thenReturn(Optional.of(userWithToken("old-access", "refresh", LocalDateTime.now().minusMinutes(1)))); + + assertThat(refresher.readValidAccessToken(10L)).isEmpty(); + } + + private User userWithToken(String accessToken, String refreshToken, LocalDateTime expiresAt) { + return new User( + 10L, + new GithubId("123"), + "octo", + "avatar", + 55L, + accessToken, + refreshToken, + expiresAt + ); + } +}