Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion TEST_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동

실제 OS·Health Connect 권한창과 서버 연결을 함께 거치는 실기기 검증은 아래 환경 항목에 남아 있다.

### 2026-10-29 토큰 갱신 경합 (#642)

- `TokenAuthenticatorTest`: 동시에 만료된 요청이 하나의 갱신 결과로 재시도하는지 확인한다.
- `TokenRefresherImplTest`: 오래된 토큰 재사용과 대기자 취소 중 중복 갱신, 자동 로그인 갱신과 401 재시도가 겹칠 때 하나의 요청을 공유하는지 확인한다.
- `TokenRepositoryImplTest`: 갱신 중 로그인·로그아웃으로 세션이 바뀌면 이전 결과로 덮거나 지우지 않는지 확인한다.
- 실제 서버의 재사용 탐지 로그와 기기에서의 앱 시작·복귀 흐름은 별도 검증이 필요하다.

품질 논의는 이 절에서 한다. 각 항목은 *무엇을 못 잡는가 · 왜 안 했나 · 풀리는 조건*을 적는다.

### 코드로 메울 수 있는 것
Expand Down Expand Up @@ -138,7 +145,7 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동
| 런타임 권한 다이얼로그·지오펜스 | Robolectric 이 못 흉내낸다 | 에뮬레이터 CI 워크플로 |
| 계측 테스트 8건이 CI 밖 | `test.yml` 이 `./gradlew test` 만 돈다 | 위와 같은 워크플로 |
| ~~인수 테스트 실행 확인~~ | — | **2026-09-07 해소.** 시크릿을 받아 스테이징에서 14건 전부 통과시켰다. 그 과정에서 하네스 버그 둘(봉투 미해제·`dev/tokens` 경로 이중 `/api`)과 `#417`(4xx 미변환)을 잡았다 | — |
| `TokenAuthenticator` 401 갱신 | 자동 로그아웃 분기가 어긋나면 전 사용자가 튕긴다 | `core:network` 에 테스트 소스셋이 **생겼다**(`ErrorBodyInterceptorTest`) — 이제 막는 건 시간뿐이다 | 이어서 진행 |
| 토큰 갱신 실서버 검증 | 서버의 refresh 회전·재사용 탐지와 앱 시작 경합을 함께 검증하지 못한다 | 로컬 테스트는 서버와 실제 기기를 사용하지 않는다 | 배포 후 SEC-05와 refresh_rotation_grace 로그 확인 |


### 2026-09-28 · 권한·공통 모델·화면 계측
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.IOException
import javax.inject.Inject

Expand All @@ -32,6 +34,8 @@ class TokenRepositoryImpl
@Volatile
private var cachedAccess: String? = null

private val mutationLock = Mutex()

/** 읽기 경로의 단일 입구. */
private val preferences: Flow<Preferences> =
dataStore.data.catch { cause ->
Expand All @@ -50,7 +54,7 @@ class TokenRepositoryImpl
override suspend fun saveSession(
token: Token,
userId: String,
) {
) = mutationLock.withLock {
// 캐시를 먼저 채운다
cachedAccess = token.accessToken
// 한 번의 edit 이라 원자적이다.
Expand All @@ -65,7 +69,7 @@ class TokenRepositoryImpl
override suspend fun saveTokens(
token: Token,
userId: String?,
) {
) = mutationLock.withLock {
cachedAccess = token.accessToken
write("saveTokens") { prefs ->
prefs[KEY_ACCESS] = token.accessToken
Expand All @@ -77,29 +81,67 @@ class TokenRepositoryImpl
}
}

override suspend fun saveTokensIfRefreshTokenMatches(
expectedRefreshToken: String,
token: Token,
userId: String?,
): Boolean =
mutationLock.withLock {
var saved = false
dataStore.edit { prefs ->
if (prefs[KEY_REFRESH] == expectedRefreshToken) {
prefs[KEY_ACCESS] = token.accessToken
prefs[KEY_REFRESH] = token.refreshToken
userId?.let { prefs[KEY_USER_ID] = it }
prefs[KEY_EVER_LOGGED_IN] = true
saved = true
}
}
if (saved) cachedAccess = token.accessToken
saved
}

override suspend fun getAccessToken(): String? =
preferences
.map { it[KEY_ACCESS] }
.first()
.also { cachedAccess = it }
mutationLock.withLock {
preferences
.map { it[KEY_ACCESS] }
.first()
.also { cachedAccess = it }
}

override fun cachedAccessToken(): String? = cachedAccess

override suspend fun getRefreshToken(): String? = preferences.map { it[KEY_REFRESH] }.first()
override suspend fun getRefreshToken(): String? = mutationLock.withLock { preferences.map { it[KEY_REFRESH] }.first() }

override suspend fun getUserId(): String? = preferences.map { it[KEY_USER_ID] }.first()

override suspend fun hasEverLoggedIn(): Boolean = preferences.map { it[KEY_EVER_LOGGED_IN] == true }.first()

override suspend fun clear() {
cachedAccess = null
write("clear") { prefs ->
// 로그인 이력만 남긴다.
val everLoggedIn = prefs[KEY_EVER_LOGGED_IN]
prefs.clear()
everLoggedIn?.let { prefs[KEY_EVER_LOGGED_IN] = it }
override suspend fun clear() =
mutationLock.withLock {
cachedAccess = null
write("clear") { prefs ->
// 로그인 이력만 남긴다.
val everLoggedIn = prefs[KEY_EVER_LOGGED_IN]
prefs.clear()
everLoggedIn?.let { prefs[KEY_EVER_LOGGED_IN] = it }
}
}

override suspend fun clearIfRefreshTokenMatches(expectedRefreshToken: String): Boolean =
mutationLock.withLock {
var cleared = false
dataStore.edit { prefs ->
if (prefs[KEY_REFRESH] == expectedRefreshToken) {
val everLoggedIn = prefs[KEY_EVER_LOGGED_IN]
prefs.clear()
everLoggedIn?.let { prefs[KEY_EVER_LOGGED_IN] = it }
cleared = true
}
}
if (cleared) cachedAccess = null
cleared
}
}

/** 쓰기 경로의 단일 입구. */
private suspend fun write(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,48 @@ class TokenRepositoryImplTest {
assertEquals("u-1", repo.getUserId())
}

@Test
fun `갱신 중 세션이 바뀌면 이전 응답으로 새 세션을 덮지 않는다`() =
runTest {
val repo = repo(FakeDataStore())
repo.saveSession(token(), userId = "old-user")
repo.saveSession(token(access = "new-access", refresh = "new-refresh"), userId = "new-user")

val saved = repo.saveTokensIfRefreshTokenMatches("r1", token(access = "stale", refresh = "stale-refresh"), "old-user")

assertFalse(saved)
assertEquals("new-refresh", repo.getRefreshToken())
assertEquals("new-access", repo.cachedAccessToken())
assertEquals("new-user", repo.getUserId())
}

@Test
fun `로그아웃 뒤 도착한 갱신 응답은 세션을 되살리지 않는다`() =
runTest {
val repo = repo(FakeDataStore())
repo.saveSession(token(), userId = "u-1")
repo.clear()

val saved = repo.saveTokensIfRefreshTokenMatches("r1", token(access = "a2", refresh = "r2"))

assertFalse(saved)
assertNull(repo.getRefreshToken())
assertNull(repo.cachedAccessToken())
}

@Test
fun `세션이 바뀌면 이전 토큰의 갱신 실패로 새 세션을 지우지 않는다`() =
runTest {
val repo = repo(FakeDataStore())
repo.saveSession(token(), "old-user")
repo.saveSession(token(access = "new-access", refresh = "new-refresh"), "new-user")

assertFalse(repo.clearIfRefreshTokenMatches("r1"))
assertEquals("new-refresh", repo.getRefreshToken())
assertEquals("new-access", repo.cachedAccessToken())
assertEquals("new-user", repo.getUserId())
}

@Test
fun `clear 는 저장값과 캐시를 함께 비운다`() =
runTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import com.ruleup.domain.entity.user.Token

/** accessToken 만료(401) 시 refreshToken 으로 토큰을 재발급하는 포트. */
interface TokenRefresher {
/** [refreshToken] 으로 새 토큰(회전: access/refresh 둘 다 갱신)을 재발급한다. */
/** [refreshToken] 으로 재발급한 토큰은 반환 전에 저장한다. 세션이 바뀌면 null 을 반환한다. */
suspend fun refresh(refreshToken: String): RefreshedSession?
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ interface TokenRepository {
userId: String? = null,
)

/** 갱신 중 세션이 바뀌었다면 이전 결과로 덮어쓰지 않는다. */
suspend fun saveTokensIfRefreshTokenMatches(
expectedRefreshToken: String,
token: Token,
userId: String? = null,
): Boolean

suspend fun getAccessToken(): String?

/** 마지막으로 알려진 accessToken 의 인메모리 스냅샷(동기). */
Expand All @@ -31,6 +38,8 @@ interface TokenRepository {

suspend fun clear()

suspend fun clearIfRefreshTokenMatches(expectedRefreshToken: String): Boolean

/** 이 기기에서 한 번이라도 로그인에 성공한 적이 있는지. */
suspend fun hasEverLoggedIn(): Boolean

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ class FakeTokenRepository(
loggedIn.value = true
}

override suspend fun saveTokensIfRefreshTokenMatches(
expectedRefreshToken: String,
token: Token,
userId: String?,
): Boolean {
if (refreshToken != expectedRefreshToken) return false
saveTokens(token, userId)
return true
}

override suspend fun getAccessToken(): String? = savedToken?.accessToken

override fun cachedAccessToken(): String? = savedToken?.accessToken
Expand All @@ -57,6 +67,12 @@ class FakeTokenRepository(

override suspend fun getUserId(): String? = storedUserId

override suspend fun clearIfRefreshTokenMatches(expectedRefreshToken: String): Boolean {
if (refreshToken != expectedRefreshToken) return false
clear()
return true
}

override suspend fun clear() {
cleared = true
refreshToken = null
Expand Down
2 changes: 2 additions & 0 deletions core/network/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,7 @@ dependencies {
// 에러 본문 인터셉터가 실제 OkHttp 응답을 다루므로 유닛 테스트에도 okhttp 가 필요하다.
testImplementation(libs.junit)
testImplementation(kotlin("test-junit"))
testImplementation(testFixtures(project(":core:domain")))
testImplementation(testFixtures(project(":observability:domain")))
testImplementation(libs.okhttp)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
private val tokenRefresher: Lazy<TokenRefresher>,
private val observability: Observability,
) : Authenticator {
override fun authenticate(

Check failure on line 24 in core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RuleUp-ASM_Android&issues=AaGIG7yoPD-VOnWhB8W1&open=AaGIG7yoPD-VOnWhB8W1&pullRequest=650
route: Route?,
response: Response,
): Request? {
Expand All @@ -39,6 +39,10 @@
}

val refreshToken = runBlocking { tokenRepository.getRefreshToken() } ?: return null
val accessAfterRead = tokenRepository.cachedAccessToken()
if (!accessAfterRead.isNullOrBlank() && accessAfterRead != failedToken) {
return response.retryWith(accessAfterRead)
}

val newToken =
try {
Expand All @@ -57,13 +61,17 @@
return response.retryWith(latestAccess)
}
// 정리하면 isLoggedIn 이 false 로 전이해 로그인 화면으로 라우팅된다.
observability.i(TAG) { "세션 만료 — 로컬 토큰 정리" }
runBlocking { tokenRepository.clear() }
val cleared = runBlocking { tokenRepository.clearIfRefreshTokenMatches(refreshToken) }
if (cleared) observability.i(TAG) { "세션 만료 — 로컬 토큰 정리" }
if (!cleared) {
val latest = tokenRepository.cachedAccessToken()
if (!latest.isNullOrBlank()) return response.retryWith(latest)
}
return null
}

runBlocking { tokenRepository.saveTokens(newToken.token, newToken.userId) }
return response.retryWith(newToken.token.accessToken)
val latestAccess = tokenRepository.cachedAccessToken()
return if (!latestAccess.isNullOrBlank()) response.retryWith(latestAccess) else null
}
}

Expand Down
Loading
Loading