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
5 changes: 5 additions & 0 deletions TEST_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ CI 가 남의 네트워크 사정에 인질이 된다.
그건 기계가 모른다.**


### 구글 인증 화면 복원 (#646)

- Robolectric 테스트에서 인증 요청을 저장한 상태로 액티비티를 다시 만들 때 새 인증 화면을 열지 않고, 복원된 결과에 기존 PKCE 검증값을 사용하는지 확인한다. 취소와 코드 없는 응답이 성공으로 처리되지 않는지도 확인한다.
- 실제 구글 계정으로 인증 화면 전환과 프로세스 종료 후 결과 복귀는 기기에서 확인해야 한다.

## #509 QA 수정 검증 (2026-09-28)

- 완료: 종료일 기준 D-day, 복제 초안 1회 전달, 초기 캘린더 선택일 조회, 제재 ID 누락 보존, 빈 약관 버전 폴백, 미래 생일 구분, KST 제재 시각, 429 기본 대기, 수집 대상 복구와 실패 시 기존 대상 보존, 중복 신고 코드 매핑의 회귀 검증.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.kakao.sdk.auth.AuthCodeClient
import com.kakao.sdk.user.UserApiClient
import com.ruleup.onboarding.presentation.BuildConfig
import com.ruleup.onboarding.presentation.oauth.util.PkceUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
Expand Down Expand Up @@ -49,6 +50,8 @@ class OAuthActivity : ComponentActivity() {
return
}

googleRequest = savedInstanceState?.getString(STATE_GOOGLE_REQUEST)?.let(AuthorizationRequest::jsonDeserialize)

lifecycleScope.launch {
try {
val result =
Expand All @@ -67,6 +70,8 @@ class OAuthActivity : ComponentActivity() {
}
}
finishWithSuccess(result)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
finishWithError(e.message ?: "인증 실패")
}
Expand Down Expand Up @@ -107,15 +112,14 @@ class OAuthActivity : ComponentActivity() {

/** 구글 OAuth 2.0 인가 코드 + PKCE. */
private suspend fun authorizeGoogle(): OAuthResult {
val config =
AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token"),
)
val restoredRequest = googleRequest
val request =
AuthorizationRequest
restoredRequest ?: AuthorizationRequest
.Builder(
config,
AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token"),
),
BuildConfig.GOOGLE_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.GOOGLE_REDIRECT_URI),
Expand All @@ -125,16 +129,21 @@ class OAuthActivity : ComponentActivity() {
val deferred = CompletableDeferred<AuthorizationResponse>()
googleRequest = request
googleResult = deferred
googleAuthLauncher.launch(authService.getAuthorizationRequestIntent(request))
if (restoredRequest == null) googleAuthLauncher.launch(authService.getAuthorizationRequestIntent(request))
val response = deferred.await()

return OAuthResult(
code = response.authorizationCode ?: error("구글 인가 코드를 받지 못했습니다."),
codeVerifier = request.codeVerifier ?: error("PKCE code_verifier 가 없습니다."),
redirectUri = BuildConfig.GOOGLE_REDIRECT_URI,
redirectUri = request.redirectUri.toString(),
)
}

override fun onSaveInstanceState(outState: Bundle) {
googleRequest?.let { outState.putString(STATE_GOOGLE_REQUEST, it.jsonSerializeString()) }
super.onSaveInstanceState(outState)
}

override fun onDestroy() {
if (googleRequest != null) authService.dispose()
super.onDestroy()
Expand Down Expand Up @@ -169,5 +178,6 @@ class OAuthActivity : ComponentActivity() {
const val EXTRA_VERIFIER = "verifier"
const val EXTRA_REDIRECT = "redirect"
const val EXTRA_ERROR = "error"
private const val STATE_GOOGLE_REQUEST = "google_authorization_request"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.ruleup.onboarding.presentation.oauth

import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Looper
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class OAuthActivityTest {
@Test
fun `재생성된 구글 인증을 취소하면 취소 결과를 돌려준다`() {
assertRestoredFailure("구글 인증이 취소되었습니다.") { null }
}

@Test
fun `재생성된 구글 인증에 코드가 없으면 성공으로 처리하지 않는다`() {
assertRestoredFailure("구글 인가 코드를 받지 못했습니다.") { request ->
AuthorizationResponse.Builder(request).build().toIntent()
}
}

@Test
fun `재생성된 구글 인증 화면은 기존 요청의 코드 검증값으로 결과를 반환한다`() {
val config =
AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token"),
)
val request =
AuthorizationRequest
.Builder(config, "test-client", ResponseTypeValues.CODE, Uri.parse("com.example.test:/oauth"))
.setScope("openid email profile")
.build()
val state = Bundle().apply { putString("google_authorization_request", request.jsonSerializeString()) }
val intent = Intent().putExtra(OAuthActivity.EXTRA_PROVIDER, "GOOGLE")
val controller = Robolectric.buildActivity(OAuthActivity::class.java, intent).create(state)
val activity = controller.get()
val registry = activity.activityResultRegistry
val registryState = Bundle()
registry.onSaveInstanceState(registryState)
val key = requireNotNull(registryState.getStringArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS")).single()
val requestCode = requireNotNull(registryState.getIntegerArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_RCS")).single()
registryState.putStringArrayList("KEY_COMPONENT_ACTIVITY_LAUNCHED_KEYS", arrayListOf(key))
registry.onRestoreInstanceState(registryState)

val authResult =
AuthorizationResponse
.Builder(request)
.setAuthorizationCode("authorization-code")
.build()
.toIntent()
assertTrue(registry.dispatchResult(requestCode, Activity.RESULT_OK, authResult))
controller.start().resume()
shadowOf(Looper.getMainLooper()).idle()

val shadow = shadowOf(activity)
assertTrue(activity.isFinishing)
assertEquals(Activity.RESULT_OK, shadow.resultCode)
assertEquals("authorization-code", shadow.resultIntent.getStringExtra(OAuthActivity.EXTRA_CODE))
assertEquals(request.codeVerifier, shadow.resultIntent.getStringExtra(OAuthActivity.EXTRA_VERIFIER))
assertEquals(request.redirectUri.toString(), shadow.resultIntent.getStringExtra(OAuthActivity.EXTRA_REDIRECT))
controller.pause().stop().destroy()
}

@Test
fun `구글 인증 중 화면이 재생성되면 기존 요청을 기다리고 새 인증 화면은 열지 않는다`() {
val config =
AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token"),
)
val request =
AuthorizationRequest
.Builder(config, "test-client", ResponseTypeValues.CODE, Uri.parse("com.example.test:/oauth"))
.setScope("openid email profile")
.build()
val state = Bundle().apply { putString("google_authorization_request", request.jsonSerializeString()) }
val intent = Intent().putExtra(OAuthActivity.EXTRA_PROVIDER, "GOOGLE")
val controller =
Robolectric
.buildActivity(OAuthActivity::class.java, intent)
.create(state)
.start()
.resume()
shadowOf(Looper.getMainLooper()).idle()
val activity = controller.get()

assertFalse(activity.isFinishing)
assertNull(shadowOf(activity).nextStartedActivity)
val nextState = Bundle()
controller.saveInstanceState(nextState)
val savedRequest = AuthorizationRequest.jsonDeserialize(nextState.getString("google_authorization_request")!!)
assertEquals(request.codeVerifier, savedRequest.codeVerifier)
controller.pause().stop().destroy()
assertFalse(activity.isFinishing)
}

private fun assertRestoredFailure(
expectedError: String,
resultData: (AuthorizationRequest) -> Intent?,
) {
val config =
AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token"),
)
val request =
AuthorizationRequest
.Builder(config, "test-client", ResponseTypeValues.CODE, Uri.parse("com.example.test:/oauth"))
.setScope("openid email profile")
.build()
val state = Bundle().apply { putString("google_authorization_request", request.jsonSerializeString()) }
val controller =
Robolectric
.buildActivity(
OAuthActivity::class.java,
Intent().putExtra(OAuthActivity.EXTRA_PROVIDER, "GOOGLE"),
).create(state)
val activity = controller.get()
val registry = activity.activityResultRegistry
val registryState = Bundle()
registry.onSaveInstanceState(registryState)
val key = requireNotNull(registryState.getStringArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_KEYS")).single()
val requestCode = requireNotNull(registryState.getIntegerArrayList("KEY_COMPONENT_ACTIVITY_REGISTERED_RCS")).single()
registryState.putStringArrayList("KEY_COMPONENT_ACTIVITY_LAUNCHED_KEYS", arrayListOf(key))
registry.onRestoreInstanceState(registryState)

assertTrue(registry.dispatchResult(requestCode, Activity.RESULT_CANCELED, resultData(request)))
controller.start().resume()
shadowOf(Looper.getMainLooper()).idle()

val shadow = shadowOf(activity)
assertTrue(activity.isFinishing)
assertEquals(Activity.RESULT_CANCELED, shadow.resultCode)
assertEquals(expectedError, shadow.resultIntent.getStringExtra(OAuthActivity.EXTRA_ERROR))
assertNull(shadow.resultIntent.getStringExtra(OAuthActivity.EXTRA_CODE))
controller.pause().stop().destroy()
}
}
Loading