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
10 changes: 10 additions & 0 deletions TEST_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,16 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동
| 약관 재동의·철회가 사유별로 갈린다 | **인수 있음** | `AccountContractAcceptanceTest`(철회 금지·버전 불일치) · `AgreementsViewModelTest` · `ErrorBodyInterceptorTest` |
| 인증 제출 → 오늘 상태가 바뀜 | 하위만 | `RunSyncUseCaseTest` · `SubmitDeviceIntroUseCaseTest` · `TodayStatusTest` |

### 심사 계정 로그인 (#645)

- `ReviewLoginApiTest`: 코드만 전송하고 기존 Authorization 헤더와 디버그 로그를 남기지 않으며, 401에서 기존 토큰으로 재시도하지 않는지 확인한다. 잘못된 코드·횟수 제한·기능 비활성화 응답도 구분한다.
- `ReviewLoginUseCaseTest`, `TokenRepositoryImplTest`: 심사 세션 저장과 실패, 갱신 및 로그아웃 경로를 확인한다. 심사 세션 저장 중 로그아웃과 이전 세션의 늦은 갱신 결과도 확인한다.
- `ReviewLoginFailureUiTest`: 잘못된 코드, 횟수 제한, 기능 비활성화에 따른 안내를 구분한다.
- `LoginViewModelTest`: 심사 세션 저장 후 홈 진입, 코드 오류 안내와 중복 제출 방지를 확인한다.
- `LoginContentTest`: 로고 6회 탭으로 진입하는지 확인한다.
- `ReviewLoginDialogDeviceTest`: 오프라인 Android 에뮬레이터에서 빈 코드의 제출 차단, 입력값 전달, 취소 및 진행 중 중복 제출 방지를 확인한다. Robolectric에서는 텍스트 렌더링 중 메모리 부족으로 실행하지 못했다.
- 서버 심사 로그인 API는 아직 구현되지 않아 실제 서버 연동을 확인하지 못했다. 사용자 요청에 따라 Meta 이벤트 차단은 이번 작업에서 다루지 않는다.

## 5. 돌리는 법

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ class TokenRepositoryImpl
override val isLoggedIn: Flow<Boolean> =
preferences.map { prefs -> prefs[KEY_REFRESH] != null }

override val isReviewSession: Flow<Boolean> =
dataStore.data.map { prefs -> prefs[KEY_REVIEW_SESSION] == true && prefs[KEY_REFRESH] != null }

// 갱신 응답이 userId 를 안 주는 배포본에서는 비어 있을 수 있다
override val userId: Flow<String?> =
preferences.map { prefs -> prefs[KEY_USER_ID] }
Expand All @@ -63,7 +66,22 @@ class TokenRepositoryImpl
prefs[KEY_REFRESH] = token.refreshToken
prefs[KEY_USER_ID] = userId
prefs[KEY_EVER_LOGGED_IN] = true
prefs.remove(KEY_REVIEW_SESSION)
}
}

override suspend fun saveReviewSession(
token: Token,
userId: String,
) = mutationLock.withLock {
dataStore.edit { prefs ->
prefs[KEY_ACCESS] = token.accessToken
prefs[KEY_REFRESH] = token.refreshToken
prefs[KEY_USER_ID] = userId
prefs[KEY_EVER_LOGGED_IN] = true
prefs[KEY_REVIEW_SESSION] = true
}
cachedAccess = token.accessToken
}

override suspend fun saveTokens(
Expand Down Expand Up @@ -160,5 +178,6 @@ class TokenRepositoryImpl
private val KEY_REFRESH = stringPreferencesKey("refresh_token")
private val KEY_USER_ID = stringPreferencesKey("user_id")
private val KEY_EVER_LOGGED_IN = booleanPreferencesKey("has_ever_logged_in")
private val KEY_REVIEW_SESSION = booleanPreferencesKey("review_session")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import com.ruleup.observability.domain.event.DiagnosticPayload
import com.ruleup.observability.domain.model.Severity
import com.ruleup.observability.domain.test.RecordingSink
import com.ruleup.observability.domain.test.testObservability
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
Expand All @@ -23,6 +26,91 @@ import java.io.IOException

/** 저장소 고장이 앱 고장으로 번지지 않는지 검증한다. */
class TokenRepositoryImplTest {
@Test
fun `심사 세션 저장 중 로그아웃하면 최종 세션은 로그아웃 상태다`() =
runTest {
val store = BlockingDataStore()
val repository = repo(store)
repository.saveSession(token(), "normal-user")
store.blockNextWrite = true

val login =
async(
start = CoroutineStart.UNDISPATCHED,
) { repository.saveReviewSession(token("review", "review-refresh"), "review-user") }
store.writeStarted.await()
val logout = async(start = CoroutineStart.UNDISPATCHED) { repository.clear() }
assertFalse(logout.isCompleted)

store.allowWrite.complete(Unit)
login.await()
logout.await()
assertNull(repository.cachedAccessToken())
assertNull(repository.getRefreshToken())
assertFalse(repository.isReviewSession.first())
}

@Test
fun `심사 세션 전 이전 세션의 갱신 결과는 심사 세션을 바꾸지 않는다`() =
runTest {
val repository = repo(FakeDataStore())
repository.saveSession(token(), "normal-user")
repository.saveReviewSession(token("review", "review-refresh"), "review-user")

assertFalse(repository.saveTokensIfRefreshTokenMatches("r1", token("stale", "stale-refresh")))
assertFalse(repository.clearIfRefreshTokenMatches("r1"))
assertEquals("review", repository.cachedAccessToken())
assertEquals("review-refresh", repository.getRefreshToken())
assertTrue(repository.isReviewSession.first())
}

@Test
fun `심사 세션 갱신 실패로 로그아웃하면 심사 상태도 사라진다`() =
runTest {
val repository = repo(FakeDataStore())
repository.saveReviewSession(token(), "review-user")

assertTrue(repository.clearIfRefreshTokenMatches("r1"))
assertNull(repository.cachedAccessToken())
assertFalse(repository.isReviewSession.first())
}

@Test
fun `심사 세션과 토큰을 한 번에 저장하고 갱신 뒤에도 유지한다`() =
runTest {
val store = CountingDataStore()
val repository = repo(store)

repository.saveReviewSession(token(), "review-user")

assertEquals(1, store.writeCount)
assertEquals("r1", repository.getRefreshToken())
assertEquals("review-user", repository.getUserId())
assertTrue(repository.isReviewSession.first())
repository.saveTokens(token(access = "a2", refresh = "r2"))
assertTrue(repository.isReviewSession.first())
repository.clear()
assertFalse(repository.isReviewSession.first())
}

@Test
fun `심사 세션 뒤 일반 로그인은 심사 상태를 해제한다`() =
runTest {
val repository = repo(FakeDataStore())
repository.saveReviewSession(token(), "review-user")
repository.saveSession(token(access = "normal", refresh = "normal-refresh"), "normal-user")
assertFalse(repository.isReviewSession.first())
}

@Test
fun `심사 세션 저장에 실패하면 성공한 세션으로 취급하지 않는다`() =
runTest {
val repository = repo(FakeDataStore(failWritesWith = IOException("no space")))
val error = runCatching { repository.saveReviewSession(token(), "review-user") }.exceptionOrNull()
assertTrue(error is IOException)
assertNull(repository.cachedAccessToken())
}

private fun token(
access: String = "a1",
refresh: String = "r1",
Expand Down Expand Up @@ -246,6 +334,24 @@ private class FakeDataStore(
}
}

private class BlockingDataStore : DataStore<Preferences> {
private val delegate = FakeDataStore()
val writeStarted = CompletableDeferred<Unit>()
val allowWrite = CompletableDeferred<Unit>()
var blockNextWrite = false

override val data: Flow<Preferences> get() = delegate.data

override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
if (blockNextWrite) {
blockNextWrite = false
writeStarted.complete(Unit)
allowWrite.await()
}
return delegate.updateData(transform)
}
}

/** 쓰기 횟수를 세는 대역. */
private class CountingDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ interface TokenRepository {
userId: String,
)

suspend fun saveReviewSession(
token: Token,
userId: String,
)

val isReviewSession: Flow<Boolean>

/** 토큰을 회전시킨다. */
suspend fun saveTokens(
token: Token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class FakeTokenRepository(
private val everLoggedIn: Boolean = false,
) : TokenRepository {
private val loggedIn = MutableStateFlow(refreshToken != null)
private val reviewSession = MutableStateFlow(false)
private val userIdFlow = MutableStateFlow(storedUserId)

var savedToken: Token? = null
Expand All @@ -22,6 +23,8 @@ class FakeTokenRepository(

override val isLoggedIn: Flow<Boolean> = loggedIn.asStateFlow()

override val isReviewSession: Flow<Boolean> = reviewSession.asStateFlow()

override val userId: Flow<String?> = userIdFlow.asStateFlow()

override suspend fun saveSession(
Expand All @@ -33,6 +36,15 @@ class FakeTokenRepository(
storedUserId = userId
userIdFlow.value = userId
loggedIn.value = true
reviewSession.value = false
}

override suspend fun saveReviewSession(
token: Token,
userId: String,
) {
saveSession(token, userId)
reviewSession.value = true
}

override suspend fun saveTokens(
Expand Down Expand Up @@ -79,6 +91,7 @@ class FakeTokenRepository(
storedUserId = null
userIdFlow.value = null
loggedIn.value = false
reviewSession.value = false
}

override suspend fun hasEverLoggedIn(): Boolean = everLoggedIn || savedToken != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class TokenAuthenticator
): Request? {
// refresh 자체의 401 까지 갱신하러 들어가면 무한 재귀에 빠진다.
val requestPath = response.request.url.encodedPath
if (requestPath.contains(AUTH_REFRESH_PATH)) return null
if (requestPath.contains(AUTH_REFRESH_PATH) || requestPath.contains(AUTH_REVIEW_LOGIN_PATH)) return null
if (responseCount(response) >= MAX_ATTEMPTS) return null

synchronized(this) {
Expand Down Expand Up @@ -94,6 +94,7 @@ class TokenAuthenticator
private companion object {
const val TAG = "TokenAuthenticator"
const val AUTH_REFRESH_PATH = "/auth/refresh"
const val AUTH_REVIEW_LOGIN_PATH = "/auth/review-login"
const val HEADER_AUTHORIZATION = "Authorization"
const val BEARER_PREFIX = "Bearer "
const val MAX_ATTEMPTS = 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ class ErrorBodyInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.isSuccessful) return response
if (response.code == 404 &&
response.request.url.encodedPath
.contains("/auth/review-login")
) {
return response
}

val body = response.body ?: return response
val contentType = body.contentType()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ object NetworkModule {
private val NO_AUTH_PATHS =
listOf(
"/auth/oauth",
REVIEW_LOGIN_PATH,
"/auth/signup",
"/auth/refresh",
)
Expand Down Expand Up @@ -94,7 +95,19 @@ object NetworkModule {
redactHeader("Authorization")
redactHeader("Cookie")
}
builder.addInterceptor(loggingInterceptor)
builder.addInterceptor(
Interceptor { chain ->
if (chain
.request()
.url.encodedPath
.contains(REVIEW_LOGIN_PATH)
) {
chain.proceed(chain.request())
} else {
loggingInterceptor.intercept(chain)
}
},
)
}

return builder.build()
Expand Down Expand Up @@ -132,4 +145,6 @@ object NetworkModule {
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}

private const val REVIEW_LOGIN_PATH = "/auth/review-login"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.ruleup.onboarding.data.auth.api
import com.ruleup.network.dto.BaseResponse
import com.ruleup.network.dto.EmptyData
import com.ruleup.onboarding.data.auth.dto.LogoutRequest
import com.ruleup.onboarding.data.auth.dto.ReviewLoginRequest
import com.ruleup.onboarding.data.auth.dto.SignUpRequest
import com.ruleup.onboarding.data.auth.dto.SignUpResponse
import com.ruleup.onboarding.data.auth.dto.SocialLoginAuthRequest
Expand All @@ -17,6 +18,11 @@ import retrofit2.http.POST
import retrofit2.http.Path

interface AuthApi {
@POST("v1/auth/review-login")
suspend fun reviewLogin(
@Body request: ReviewLoginRequest,
): BaseResponse<SocialLoginAuthResponse>

// OAuth 로그인 (provider: kakao / google).
@POST("v1/auth/oauth/{provider}")
suspend fun socialLogin(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import com.ruleup.onboarding.domain.auth.entity.PermissionSnapshot
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ReviewLoginRequest(
@SerialName("code") val code: String,
)

@Serializable
data class SocialLoginAuthRequest(
@SerialName("code")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ internal fun ApiException.toAuthFailure(): AuthFailure = AUTH_FAILURE_CODES[code
private val AUTH_FAILURE_CODES: Map<String, AuthFailure> =
mapOf(
"LOGIN_FAILED" to AuthFailure.LOGIN_FAILED,
"INVALID_REVIEW_CODE" to AuthFailure.INVALID_REVIEW_CODE,
"TOO_MANY_ATTEMPTS" to AuthFailure.REVIEW_TOO_MANY_ATTEMPTS,
"INVALID_REDIRECT_URI" to AuthFailure.INVALID_REDIRECT_URI,
"INVALID_DEVICE_INFO" to AuthFailure.INVALID_DEVICE_INFO,
"LOGIN_PROVIDER_UNAVAILABLE" to AuthFailure.PROVIDER_UNAVAILABLE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.ruleup.network.dto.getOrThrow
import com.ruleup.network.dto.throwOnError
import com.ruleup.onboarding.data.auth.api.AuthApi
import com.ruleup.onboarding.data.auth.dto.LogoutRequest
import com.ruleup.onboarding.data.auth.dto.ReviewLoginRequest
import com.ruleup.onboarding.data.auth.dto.SignUpRequest
import com.ruleup.onboarding.data.auth.dto.SocialLoginAuthRequest
import com.ruleup.onboarding.data.auth.dto.WithdrawRequest
Expand All @@ -26,6 +27,7 @@ import com.ruleup.onboarding.domain.auth.entity.PermissionSnapshot
import com.ruleup.onboarding.domain.auth.entity.SignupForm
import com.ruleup.onboarding.domain.auth.entity.Withdrawal
import com.ruleup.onboarding.domain.auth.repository.AuthRepository
import retrofit2.HttpException
import java.io.IOException
import java.time.format.DateTimeFormatter
import javax.inject.Inject
Expand All @@ -38,6 +40,18 @@ class AuthRepositoryImpl
private val tokenRefresher: TokenRefresher,
private val signupInviteStore: com.ruleup.onboarding.domain.auth.repository.SignupInviteStore,
) : AuthRepository {
override suspend fun reviewLogin(code: String): AuthSession =
try {
mapAuthFailure {
val result = api.reviewLogin(ReviewLoginRequest(code)).getOrThrow().toOAuthResult()
(result as? OAuthResult.ExistingUser)?.session
?: throw ApiException(code = "INVALID_REVIEW_RESPONSE", message = "심사 계정 응답이 올바르지 않습니다.")
}
} catch (error: HttpException) {
if (error.code() == 404) throw AuthException(AuthFailure.REVIEW_UNAVAILABLE, cause = error)
throw error
}

override suspend fun exchangeToken(
authorization: OAuthAuthorization,
device: DeviceIdentity,
Expand Down
Loading
Loading