diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index 6b88a8c79..c5b760f59 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -195,6 +195,11 @@ CI 가 남의 네트워크 사정에 인질이 된다. 그건 기계가 모른다.** +### 구글 인증 화면 복원 (#646) + +- Robolectric 테스트에서 인증 요청을 저장한 상태로 액티비티를 다시 만들 때 새 인증 화면을 열지 않고, 복원된 결과에 기존 PKCE 검증값을 사용하는지 확인한다. 취소와 코드 없는 응답이 성공으로 처리되지 않는지도 확인한다. +- 실제 구글 계정으로 인증 화면 전환과 프로세스 종료 후 결과 복귀는 기기에서 확인해야 한다. + ## #509 QA 수정 검증 (2026-09-28) - 완료: 종료일 기준 D-day, 복제 초안 1회 전달, 초기 캘린더 선택일 조회, 제재 ID 누락 보존, 빈 약관 버전 폴백, 미래 생일 구분, KST 제재 시각, 429 기본 대기, 수집 대상 복구와 실패 시 기존 대상 보존, 중복 신고 코드 매핑의 회귀 검증. diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/oauth/OAuthActivity.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/oauth/OAuthActivity.kt index 4602d4ee7..a4ea4ce2a 100644 --- a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/oauth/OAuthActivity.kt +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/oauth/OAuthActivity.kt @@ -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 @@ -49,6 +50,8 @@ class OAuthActivity : ComponentActivity() { return } + googleRequest = savedInstanceState?.getString(STATE_GOOGLE_REQUEST)?.let(AuthorizationRequest::jsonDeserialize) + lifecycleScope.launch { try { val result = @@ -67,6 +70,8 @@ class OAuthActivity : ComponentActivity() { } } finishWithSuccess(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { finishWithError(e.message ?: "인증 실패") } @@ -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), @@ -125,16 +129,21 @@ class OAuthActivity : ComponentActivity() { val deferred = CompletableDeferred() 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() @@ -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" } } diff --git a/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/oauth/OAuthActivityTest.kt b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/oauth/OAuthActivityTest.kt new file mode 100644 index 000000000..7c5d7ac17 --- /dev/null +++ b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/oauth/OAuthActivityTest.kt @@ -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() + } +}