Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -749,10 +749,6 @@ private class CompletionReceiverRepository : ReceiverRepository {
authCode.value = code
}

override suspend fun clearAuthCode() {
authCode.value = null
}

override fun getPagedReceivedAfternotes(): Flow<PagingData<AfterNoteListItem>> = flowOf(PagingData.empty())

override suspend fun getReceivedAfterNotes(): Result<AfterNotesListResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,6 @@ private class AdvancedReceiverRepository : ReceiverRepository {
authCode.value = code
}

override suspend fun clearAuthCode() {
authCode.value = null
}

override fun getPagedReceivedAfternotes(): Flow<PagingData<AfterNoteListItem>> = flowOf(PagingData.empty())

override suspend fun getReceivedAfterNotes(): Result<AfterNotesListResult> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,6 @@ class FakeUserProfileRepository : UserProfileRepository {
override suspend fun saveUserName(name: String) {
cachedUserName = name
}

override suspend fun clear() {
cachedUserName = null
passkeyRegistered.value = false
}
}

class FakeDiaryRepository(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,4 @@ class UserProfileRepositoryImpl
override suspend fun saveUserName(name: String) {
dataSource.saveUserName(name)
}

override suspend fun clear() {
dataSource.clear()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.afternote.core.data.repoimpl.auth

import com.afternote.core.common.result.runCatchingCancellable
import com.afternote.core.data.mapper.auth.AuthMapper
import com.afternote.core.datastore.LocalStoreRegistry
import com.afternote.core.datastore.StoreScope
import com.afternote.core.datastore.TokenDataSource
import com.afternote.core.domain.error.InvalidLoginCredentialsException
import com.afternote.core.domain.error.NetworkUnavailableException
Expand Down Expand Up @@ -30,15 +32,14 @@ class AuthRepositoryImpl
private val tokenApiService: TokenApiService,
// 발급(로그인) 응답의 expiresIn 으로 선제 reissue deadline 을 기록하고, 세션 종료 시 함께 정리한다 (#408/#410).
private val expiryTracker: AccessTokenExpiryTracker,
// 로그아웃·탈퇴 시 SESSION 스코프 로컬 저장소 일괄 정리 (#912).
private val localStoreRegistry: LocalStoreRegistry,
) : AuthRepository {
override suspend fun clearSession() =
runCatchingCancellable {
tokenDataSource.clearTokens()
// deadline 은 세션의 산물 — 세션을 끝내는 책임이 여기 있으므로 토큰과 함께 정리한다.
// 남기면 재로그인 후 이전 토큰 기준 deadline 으로 임박을 오판한다.
// TokenDataSource.clearTokens() 내부로 내리지 않는 이유: datastore 는 저장만 아는
// 잎 모듈이라 network 의 tracker 를 알면 의존 방향이 뒤집히고, 기록(record)은 이
// 레이어(로그인 응답·TokenReissuer)가 하므로 폐기만 내리면 수명 관리가 두 레이어로 찢어진다.
localStoreRegistry.clearScope(StoreScope.SESSION)
// tracker 는 network 계층 in-memory 상태라 레지스트리(디스크 저장소 전담) 관할 밖.
// 남기면 재로그인 후 이전 토큰 기준 deadline 으로 만료 임박을 오판한다.
expiryTracker.clear()
}

Expand Down Expand Up @@ -128,20 +129,18 @@ class AuthRepositoryImpl

/**
* 서버 로그아웃은 best-effort (네트워크 실패해도 사용자는 로그아웃 상태로 가야 함).
* 로컬 토큰과 선제 reissue deadline 은 서버 호출 결과와 무관하게 항상 정리한다.
* SESSION 스코프 로컬 저장소와 선제 reissue deadline 은 서버 호출 결과와 무관하게 항상 정리한다.
*/
override suspend fun logout(): Result<Unit> =
runCatchingCancellable {
val refreshToken = getRefreshToken().getOrNull()
if (refreshToken != null) {
runCatchingCancellable { authApiService.logout(LogoutRequestDto(refreshToken)) }
}
tokenDataSource.clearTokens()
// deadline 은 방금 지운 액세스 토큰의 잔여 수명 기록 — 토큰이 사라지는 순간 의미를 잃으므로
// 세션을 끝내는 여기서 함께 버린다 (남기면 재로그인 후 죽은 토큰 기준으로 임박 오판).
// 반드시 위 API 호출 뒤여야 한다: 로그아웃 HTTP 요청도 AuthInterceptor 를 지나므로, 토큰이
// 만료 임박이면 요청 직전에 선제 reissue(401 을 기다리지 않고 미리 새 토큰 쌍으로 교체)가
// 일어나 tracker 에 새 deadline 이 기록될 수 있다 — clear 를 먼저 하면 그 기록이 되살아난다.
// 두 정리 모두 반드시 위 API 호출 뒤여야 한다. 로그아웃 HTTP 요청도 AuthInterceptor 를
// 지나므로 그 시점엔 토큰이 살아 있어야 하고, 토큰이 만료 임박이면 요청 직전 선제 reissue 가
// tracker 에 새 deadline 을 기록할 수 있다 — tracker 를 먼저 비우면 그 기록이 되살아난다.
localStoreRegistry.clearScope(StoreScope.SESSION)
expiryTracker.clear()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.afternote.core.data.repoimpl.auth
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import com.afternote.core.datastore.LocalStoreRegistry
import com.afternote.core.datastore.StoreScope
import com.afternote.core.datastore.TokenDataSource
import com.afternote.core.domain.error.InvalidLoginCredentialsException
import com.afternote.core.domain.error.NetworkUnavailableException
Expand Down Expand Up @@ -38,14 +40,25 @@ import kotlin.coroutines.cancellation.CancellationException
*
* 계약 — 발급(로그인) 응답의 `expiresIn` 은 기록하고, 생략(null)이면 이전 토큰 기준 stale
* deadline 이 새 세션에 적용되지 않게 비우며(`TokenReissuer` 회전 경로와 같은 규칙),
* 세션 종료(`logout`/`clearSession`)는 로컬 토큰과 deadline 을 함께 정리한다.
* 세션 종료(`logout`/`clearSession`)는 SESSION 스코프 저장소 일괄 정리(#912)와 deadline
* 정리를 함께 수행한다.
*
* [AccessTokenExpiryTracker] 는 실물 사용 — 시계(SystemClock)는 `isReturnDefaultValues` 로
* 0 에 고정되므로, 잔여 30초 기록 = 임박 true / 비움 = false 로 상태를 관찰한다 (임계 60초).
*/
class AuthRepositoryImplTest {
private val tracker = AccessTokenExpiryTracker()
private val tokenDataSource = TokenDataSource(InMemoryPreferencesDataStore())
private val tokenStore = InMemoryPreferencesDataStore()
private val tokenDataSource = TokenDataSource(tokenStore)

// 실제 레지스트리처럼 SESSION 정리가 토큰 저장소를 비우게 흉내 낸다 — 그래야 아래
// 토큰 잔존·서버 호출 순서(정리가 먼저면 refresh 가 없어 서버 호출 불가) 단언이 유효하다.
private val localStoreRegistry =
FakeLocalStoreRegistry(
onClearScope = { scope ->
if (scope == StoreScope.SESSION) tokenStore.updateData { emptyPreferences() }
},
)

private fun repository(
authApiService: AuthApiService = FakeAuthApiService(),
Expand All @@ -55,6 +68,7 @@ class AuthRepositoryImplTest {
authApiService = authApiService,
tokenApiService = tokenApiService,
expiryTracker = tracker,
localStoreRegistry = localStoreRegistry,
)

@Test
Expand Down Expand Up @@ -105,21 +119,23 @@ class AuthRepositoryImplTest {
}

@Test
fun `logout - 저장돼 있던 refresh 로 서버 호출 후 로컬 토큰·deadline 정리`() {
fun `logout - 저장돼 있던 refresh 로 서버 호출 후 SESSION 스코프·deadline 정리`() {
runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "stored-refresh") }
tracker.record(expiresInSeconds = 30)
val authApiService = FakeAuthApiService()

val result = runBlocking { repository(authApiService).logout() }

assertTrue(result.isSuccess)
// 정리가 서버 호출보다 먼저였다면 refresh 가 이미 없어 이 요청 자체가 못 나간다 (순서 가드).
assertEquals(listOf(LogoutRequestDto("stored-refresh")), authApiService.logoutRequests)
assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes)
assertNull(runBlocking { tokenDataSource.getRefreshToken() })
assertFalse(tracker.isExpiringSoon())
}

@Test
fun `logout - 서버 호출 실패해도 (best-effort) 토큰·deadline 정리는 진행`() {
fun `logout - 서버 호출 실패해도 (best-effort) SESSION 스코프·deadline 정리는 진행`() {
runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "stored-refresh") }
tracker.record(expiresInSeconds = 30)
val authApiService =
Expand All @@ -130,18 +146,20 @@ class AuthRepositoryImplTest {
val result = runBlocking { repository(authApiService).logout() }

assertTrue(result.isSuccess)
assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes)
assertNull(runBlocking { tokenDataSource.getRefreshToken() })
assertFalse(tracker.isExpiringSoon())
}

@Test
fun `clearSession - 로컬 토큰·deadline 함께 정리`() {
fun `clearSession - SESSION 스코프·deadline 함께 정리 (탈퇴 경로도 이 메서드를 쓴다)`() {
runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") }
tracker.record(expiresInSeconds = 30)

val result = runBlocking { repository().clearSession() }

assertTrue(result.isSuccess)
assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes)
assertNull(runBlocking { tokenDataSource.getAccessToken() })
assertFalse(tracker.isExpiringSoon())
}
Expand Down Expand Up @@ -331,6 +349,23 @@ private class FakeTokenApiService(
override suspend fun reissue(body: ReissueRequestDto): BaseResponse<ReissueDto> = onReissue(body)
}

/** [LocalStoreRegistry] 테스트 대역 — clearScope 호출을 기록하고, 주입된 동작으로 실제 정리를 흉내 낸다. */
private class FakeLocalStoreRegistry(
private val onClearScope: suspend (StoreScope) -> Unit = {},
) : LocalStoreRegistry {
val clearedScopes = mutableListOf<StoreScope>()

override fun store(
name: String,
scope: StoreScope,
): DataStore<Preferences> = error("store 는 이 테스트에서 호출되면 안 됨")

override suspend fun clearScope(scope: StoreScope) {
clearedScopes += scope
onClearScope(scope)
}
}

/** 단위 테스트용 in-memory `DataStore<Preferences>` — 디스크 없이 [TokenDataSource] 실물을 구동한다. */
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow(emptyPreferences())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.afternote.core.datastore

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences

/**
* 앱의 모든 Preferences DataStore 를 만들어 나눠주는 단일 창구 (#912).
*
* 각 모듈이 `preferencesDataStore(name = ...)` 델리게이트를 독립 선언하던 구조에서는
* (1) 로그아웃 정리 대상 등록을 빼먹어도 어디서도 안 걸리고, (2) 파일명 충돌을 아무도 못 막았다.
* 레지스트리 경유로 바꾸면 획득 시점에 수명([StoreScope])이 함께 등록되고, 같은 name 은
* 항상 같은 인스턴스를 돌려받아(DataStore 는 파일당 인스턴스 1개가 강제 조건) 둘 다 구조적으로 풀린다.
*
* 반환 타입은 [DataStore] 그대로다 — `Flow` 관찰·원자적 다중 키 `edit` 등 DataStore API 를
* 좁히지 않는다(#912 하지 말 것). 타입드 접근자(스키마)는 각 feature 의 DataSource 가 유지한다.
*/
interface LocalStoreRegistry {
/**
* `files/datastore/<name>.preferences_pb` 를 쓰는 DataStore 를 만들어 돌려주고,
* [scope] 를 수명으로 등록한다. 같은 [name] 재요청은 같은 인스턴스를 돌려주며,
* 같은 [name] 을 다른 [scope] 로 재요청하면 즉시 실패한다.
*
* [name] 은 저장 파일명 계약이다 — 바꾸면 기존 사용자의 데이터가 통째로 끊긴다 (#912 필수 주의).
*/
fun store(
name: String,
scope: StoreScope,
): DataStore<Preferences>

/**
* [scope] 로 등록된 모든 저장소의 키를 비운다 (파일은 유지).
*
* 등록 이력은 디스크에도 남으므로, 이번 프로세스에서 아직 획득된 적 없는 저장소도
* 함께 지워진다 — Hilt provider 는 최초 주입 때에야 실행되므로 in-memory 등록만으로는
* "이전 프로세스에서 쓰고 이번 프로세스에서 안 연" 저장소가 새는 구멍이 있다.
*/
suspend fun clearScope(scope: StoreScope)
}
Loading
Loading