diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index ec7fb24f5e68..1c4710713d9a 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,7 @@ 26.8 ----- * [**] Resolved an issue where the editor could become impossible to exit when it failed to load. +* [*] Atomic sites can now create application passwords without leaving the app. 26.7 ----- diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationPasswordsClientIdModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationPasswordsClientIdModule.kt new file mode 100644 index 000000000000..351aa41a868b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationPasswordsClientIdModule.kt @@ -0,0 +1,33 @@ +package org.wordpress.android.modules + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.wordpress.android.R +import org.wordpress.android.fluxc.module.ApplicationPasswordsClientId +import org.wordpress.android.util.BuildConfigWrapper +import org.wordpress.android.util.DeviceUtils +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object ApplicationPasswordsClientIdModule { + @Provides + @Singleton + @ApplicationPasswordsClientId + fun provideApplicationPasswordsClientId( + @ApplicationContext context: Context, + buildConfigWrapper: BuildConfigWrapper, + ): String { + val deviceName = DeviceUtils.getInstance().getDeviceName(context) + val resId = if (buildConfigWrapper.isJetpackApp) { + R.string.application_password_app_name_jetpack + } else { + R.string.application_password_app_name_wordpress + } + return context.getString(resId, deviceName) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt index 38d85c05b5d3..7b5c6c01fe0d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt @@ -49,49 +49,54 @@ class ApplicationPasswordLoginHelper @Inject constructor( ) { private var processedAppPasswordData: String? = null + sealed class DiscoveryResult { + data class Authorized(val authorizationUrl: String) : DiscoveryResult() + data class Failed(val userFacingMessage: String) : DiscoveryResult() + } + @Suppress("TooGenericExceptionCaught") - suspend fun getAuthorizationUrlComplete(siteUrl: String): String = + suspend fun getAuthorizationUrlComplete(siteUrl: String): DiscoveryResult = try { getAuthorizationUrlCompleteInternal(siteUrl) } catch (throwable: Throwable) { - handleAuthenticationDiscoveryError(siteUrl, throwable) + handleAuthenticationDiscoveryError(siteUrl, throwable.message ?: throwable::class.simpleName.orEmpty()) } - private suspend fun getAuthorizationUrlCompleteInternal(siteUrl: String): String = withContext(bgDispatcher) { - when (val urlDiscoveryResult = wpLoginClient.apiDiscovery(siteUrl)) { - is ApiDiscoveryResult.Success -> { - val authorizationUrl = - discoverSuccessWrapper.getApplicationPasswordsAuthenticationUrl(urlDiscoveryResult) - val apiRootUrl = discoverSuccessWrapper.getApiRootUrl(urlDiscoveryResult) - if (apiRootUrl.isNotEmpty()) { - // Store the ApiRootUrl for use it after the login - apiRootUrlCache.put(UrlUtils.normalizeUrl(siteUrl), apiRootUrl) + private suspend fun getAuthorizationUrlCompleteInternal(siteUrl: String): DiscoveryResult = + withContext(bgDispatcher) { + when (val urlDiscoveryResult = wpLoginClient.apiDiscovery(siteUrl)) { + is ApiDiscoveryResult.Success -> { + val authorizationUrl = + discoverSuccessWrapper.getApplicationPasswordsAuthenticationUrl(urlDiscoveryResult) + val apiRootUrl = discoverSuccessWrapper.getApiRootUrl(urlDiscoveryResult) + if (apiRootUrl.isNotEmpty()) { + // Store the ApiRootUrl for use it after the login + apiRootUrlCache.put(UrlUtils.normalizeUrl(siteUrl), apiRootUrl) + } + val authorizationUrlComplete = + uriLoginWrapper.appendParamsToRestAuthorizationUrl(authorizationUrl) + appLogWrapper.d( + AppLog.T.API, + "A_P: Found authorization for $siteUrl URL: $authorizationUrlComplete " + + "API_ROOT_URL $apiRootUrl") + AnalyticsTracker.track(Stat.BACKGROUND_REST_AUTODISCOVERY_SUCCESSFUL) + DiscoveryResult.Authorized(authorizationUrlComplete) } - val authorizationUrlComplete = - uriLoginWrapper.appendParamsToRestAuthorizationUrl(authorizationUrl) - appLogWrapper.d( - AppLog.T.API, - "A_P: Found authorization for $siteUrl URL: $authorizationUrlComplete " + - "API_ROOT_URL $apiRootUrl") - AnalyticsTracker.track(Stat.BACKGROUND_REST_AUTODISCOVERY_SUCCESSFUL) - authorizationUrlComplete - } - is ApiDiscoveryResult.FailureFetchAndParseApiRoot -> - handleAuthenticationDiscoveryError(siteUrl, Exception("FailureFetchAndParseApiRoot")) - - is ApiDiscoveryResult.FailureFindApiRoot -> - handleAuthenticationDiscoveryError(siteUrl, Exception("FailureFindApiRoot")) - - is ApiDiscoveryResult.FailureParseSiteUrl -> - handleAuthenticationDiscoveryError(siteUrl, urlDiscoveryResult.error) + is ApiDiscoveryResult.FailureFetchAndParseApiRoot, + is ApiDiscoveryResult.FailureFindApiRoot, + is ApiDiscoveryResult.FailureParseSiteUrl -> + handleAuthenticationDiscoveryError( + siteUrl, + urlDiscoveryResult.userFacingErrorMessage(siteUrl).orEmpty() + ) + } } - } - private fun handleAuthenticationDiscoveryError(siteUrl: String, throwable: Throwable): String { - appLogWrapper.e(AppLog.T.API, "A_P: Error during API discovery for $siteUrl - ${throwable.message}") + private fun handleAuthenticationDiscoveryError(siteUrl: String, message: String): DiscoveryResult { + appLogWrapper.e(AppLog.T.API, "A_P: Error during API discovery for $siteUrl - $message") AnalyticsTracker.track(Stat.BACKGROUND_REST_AUTODISCOVERY_FAILED) - return "" + return DiscoveryResult.Failed(message) } sealed class StoreCredentialsResult { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModel.kt index 550ba786091c..fd56699bbc46 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModel.kt @@ -149,8 +149,17 @@ class ApplicationPasswordAutoAuthDialogViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught") private suspend fun fallbackToManualLogin(siteUrl: String) { try { - val authUrl = applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl) - _navigationEvent.emit(NavigationEvent.FallbackToManualLogin(authUrl)) + when (val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)) { + is ApplicationPasswordLoginHelper.DiscoveryResult.Authorized -> + _navigationEvent.emit(NavigationEvent.FallbackToManualLogin(result.authorizationUrl)) + is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> { + appLogWrapper.e( + AppLog.T.API, + "A_P: Discovery failed for: $siteUrl - ${result.userFacingMessage}" + ) + _navigationEvent.emit(NavigationEvent.Error) + } + } } catch (e: Exception) { appLogWrapper.e( AppLog.T.API, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModel.kt index 1ffdbe16083a..d8ca40415a53 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModel.kt @@ -38,13 +38,16 @@ class ApplicationPasswordDialogViewModel @Inject constructor( } try { - val completeAuthUrl = applicationPasswordLoginHelper.getAuthorizationUrlComplete(authenticationUrl) - - if (completeAuthUrl.isNotEmpty()) { - _navigationEvent.emit(NavigationEvent.NavigateToLogin(completeAuthUrl)) - } else { - appLogWrapper.e(AppLog.T.MAIN, "Failed to process authentication URL") - _navigationEvent.emit(NavigationEvent.ShowError) + when (val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(authenticationUrl)) { + is ApplicationPasswordLoginHelper.DiscoveryResult.Authorized -> + _navigationEvent.emit(NavigationEvent.NavigateToLogin(result.authorizationUrl)) + is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> { + appLogWrapper.e( + AppLog.T.MAIN, + "Failed to process authentication URL - ${result.userFacingMessage}" + ) + _navigationEvent.emit(NavigationEvent.ShowError) + } } } catch (e: Throwable) { appLogWrapper.e(AppLog.T.MAIN, "Error processing authentication URL - ${e.stackTraceToString()}") diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt index 5668561e52cf..18507e797a3f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt @@ -31,9 +31,14 @@ class LoginSiteApplicationPasswordViewModel @Inject constructor( _errorMessage.value = null _loadingStateFlow.value = true discoveryJob = viewModelScope.launch { - val discoveryUrl = applicationPasswordLoginHelper - .getAuthorizationUrlComplete(siteUrl) - _discoveryURL.send(discoveryUrl) + when (val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)) { + is ApplicationPasswordLoginHelper.DiscoveryResult.Authorized -> + _discoveryURL.send(result.authorizationUrl) + is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> { + _errorMessage.value = result.userFacingMessage + _discoveryURL.send("") + } + } _loadingStateFlow.value = false } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordValidator.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordValidator.kt new file mode 100644 index 000000000000..6f4c0e7c3631 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordValidator.kt @@ -0,0 +1,59 @@ +package org.wordpress.android.ui.mysite.cards.applicationpassword + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.util.AppLog +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.RequestExecutionErrorReason +import javax.inject.Inject + +/** + * Validates that the SiteModel's application-password credentials still work against the site's + * direct host. Uses [WpApiClientProvider.getApplicationPasswordClient] so the call exercises the + * application password specifically — `getWpApiClient` would route WPCom-flagged sites through the + * bearer-token path and would not catch a revoked password. + */ +class ApplicationPasswordValidator @Inject constructor( + private val wpApiClientProvider: WpApiClientProvider, + private val appLogWrapper: AppLogWrapper, +) { + suspend fun validate(site: SiteModel): Outcome { + appLogWrapper.d( + AppLog.T.MAIN, + "A_P: Validating application password for ${site.url} as user='${site.apiRestUsernamePlain}'" + ) + return try { + val client = wpApiClientProvider.getApplicationPasswordClient(site) + val response = client.request { it.users().retrieveMeWithViewContext() } + appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation response: ${response::class.simpleName}") + when (response) { + is WpRequestResult.Success -> { + val user = response.response.data + appLogWrapper.d( + AppLog.T.MAIN, + "A_P: Validation Success returned user id=${user.id}, slug='${user.slug}', name='${user.name}'" + ) + Outcome.Valid + } + is WpRequestResult.WpError -> Outcome.Invalid + is WpRequestResult.UnknownError -> Outcome.Invalid + is WpRequestResult.RequestExecutionFailed -> + if (response.reason is RequestExecutionErrorReason.HttpTimeoutError) { + Outcome.NetworkUnavailable + } else { + Outcome.Invalid + } + else -> Outcome.Invalid + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + appLogWrapper.e( + AppLog.T.MAIN, + "A_P: Validation exception for ${site.url}: ${e::class.simpleName}: ${e.message}" + ) + Outcome.NetworkUnavailable + } + } + + enum class Outcome { Valid, Invalid, NetworkUnavailable } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index 64bca6a1e16c..622d46806e5b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -26,8 +26,6 @@ import org.wordpress.android.ui.utils.UiString.UiStringRes import org.wordpress.android.util.AppLog import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.viewmodel.Event -import rs.wordpress.api.kotlin.WpRequestResult -import uniffi.wp_api.RequestExecutionErrorReason import javax.inject.Inject import javax.inject.Named @@ -36,6 +34,7 @@ class ApplicationPasswordViewModelSlice @Inject constructor( private val siteStore: SiteStore, private val appLogWrapper: AppLogWrapper, private val wpApiClientProvider: WpApiClientProvider, + private val applicationPasswordValidator: ApplicationPasswordValidator, private val selfHostedEndpointFinder: SelfHostedEndpointFinder, private val siteXMLRPCClient: SiteXMLRPCClient, private val dispatcher: Dispatcher, @@ -57,139 +56,110 @@ class ApplicationPasswordViewModelSlice @Inject constructor( val uiModel: LiveData = uiModelMutable fun buildCard(siteModel: SiteModel) { - val storedSite = siteStore.sites.firstOrNull { it.id == siteModel.id } - - // For sites using application passwords, validate credentials - if (storedSite != null && storedSite.isUsingSelfHostedRestApi) { - validateCredentialsAndBuildCard(storedSite) - return - } - - buildApplicationPasswordDiscovery(siteModel) - } - - @Suppress("TooGenericExceptionCaught", "LongMethod") - private fun validateCredentialsAndBuildCard(site: SiteModel) { - appLogWrapper.d(AppLog.T.MAIN, "A_P: Validating credentials for ${site.url}") - appLogWrapper.d(AppLog.T.MAIN, "A_P: isUsingSelfHostedRestApi: ${site.isUsingSelfHostedRestApi}") - appLogWrapper.d(AppLog.T.MAIN, "A_P: hasApiRestUsername: ${!site.apiRestUsernamePlain.isNullOrEmpty()}") - appLogWrapper.d(AppLog.T.MAIN, "A_P: hasApiRestPassword: ${!site.apiRestPasswordPlain.isNullOrEmpty()}") - appLogWrapper.d(AppLog.T.MAIN, "A_P: wpApiRestUrl: ${site.wpApiRestUrl}") - appLogWrapper.d(AppLog.T.MAIN, "A_P: origin: ${site.origin}") - - // If credentials are missing, show reauthentication banner immediately - if (applicationPasswordLoginHelper.siteHasBadCredentials(site)) { - appLogWrapper.d(AppLog.T.MAIN, "A_P: Credentials missing for ${site.url}, showing banner") - buildReauthenticationBanner(site) - return - } - - // Validate credentials by making a simple API call scope.launch { - try { - appLogWrapper.d(AppLog.T.MAIN, "A_P: Making API call to validate credentials") - val client = wpApiClientProvider.getWpApiClient(site) - val response = client.request { requestBuilder -> - requestBuilder.users().retrieveMeWithViewContext() - } - appLogWrapper.d(AppLog.T.MAIN, "A_P: API response type: ${response::class.simpleName}") - when (response) { - is WpRequestResult.Success -> { - appLogWrapper.d( - AppLog.T.MAIN, - "A_P: Credentials valid for ${site.url}" - ) - if (site.xmlRpcUrl.isNullOrEmpty()) { - buildXmlRpcDisabledCard(site) - attemptXmlRpcRediscovery(site) - } else { - uiModelMutable.postValue(null) - } + val storedSite = siteStore.sites.firstOrNull { it.id == siteModel.id } ?: siteModel + val hadCreds = !applicationPasswordLoginHelper.siteHasBadCredentials(storedSite) + + // Step 1: if we already have stored creds, validate them with Basic auth against the + // direct host. This actually exercises the application password (unlike + // WpApiClientProvider.getWpApiClient, which routes WPCom-flagged sites through the + // bearer-token path and would not catch a revoked password). + if (hadCreds) { + when (applicationPasswordValidator.validate(storedSite)) { + ApplicationPasswordValidator.Outcome.Valid -> { + handleValidAuth(storedSite) + return@launch } - is WpRequestResult.WpError -> { - appLogWrapper.d(AppLog.T.MAIN, "A_P: WpError for ${site.url}: ${response.response}") - buildReauthenticationBanner(site) + ApplicationPasswordValidator.Outcome.NetworkUnavailable -> { + // Don't punish flaky networks — leave the card hidden and try again next time. + uiModelMutable.postValue(null) + appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${storedSite.url}") + return@launch } - is WpRequestResult.UnknownError -> { - appLogWrapper.d( - AppLog.T.MAIN, - "A_P: UnknownError for ${site.url}: code=${response.statusCode}, msg=${response.response}" - ) - buildReauthenticationBanner(site) - } - is WpRequestResult.RequestExecutionFailed -> { - val isTimeout = response.reason is RequestExecutionErrorReason.HttpTimeoutError - if (isTimeout) { - appLogWrapper.d(AppLog.T.MAIN, "A_P: Request timed out for ${site.url}") - } else { - appLogWrapper.d( - AppLog.T.MAIN, - "A_P: RequestExecutionFailed for ${site.url}: " + - "reason=${response.reason}, statusCode=${response.statusCode}" - ) - } - // Don't show reauthentication banner for timeouts - it's likely a network issue - if (!isTimeout) { - buildReauthenticationBanner(site) - } else { - uiModelMutable.postValue(null) - } - } - else -> { - // Credentials are invalid, show reauthentication banner - appLogWrapper.d(AppLog.T.MAIN, "A_P: Other error for ${site.url}: $response") - buildReauthenticationBanner(site) + ApplicationPasswordValidator.Outcome.Invalid -> { + // Stored creds are stale (revoked, deleted, etc.) — clear them so the next + // mint creates fresh ones, and invalidate the cached client. + appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${storedSite.url}, clearing") + siteStore.deleteStoredApplicationPasswordCredentials(storedSite) + wpApiClientProvider.clearSelfHostedClient(storedSite.id) } } - } catch (e: Exception) { - appLogWrapper.e( - AppLog.T.MAIN, - "A_P: Exception validating credentials for ${site.url}: ${e::class.simpleName}: ${e.message}" - ) - uiModelMutable.postValue(null) } - } - } - private fun buildReauthenticationBanner(site: SiteModel) { - scope.launch { - val authorizationUrlComplete = applicationPasswordLoginHelper.getAuthorizationUrlComplete(site.url) - if (authorizationUrlComplete.isEmpty()) { - uiModelMutable.postValue(null) - appLogWrapper.d(AppLog.T.MAIN, "A_P: Hiding reauthentication card for ${site.url} - bad discovery") + // Step 2: mint a fresh application password via the FluxC Jetpack tunnel. wordpress-rs + // can't do this today — the WP.com REST proxy doesn't expose the application-passwords + // endpoint under /wp/v2/sites/{id}/... (see Automattic/wordpress-rs#1350) — so FluxC's + // Jetpack-tunnel client is the only working path for Atomic / Jetpack-WPCom-REST sites. + val createResult = siteStore.createApplicationPassword(storedSite) + if (!createResult.isError && createResult.credentials != null) { + wpApiClientProvider.clearSelfHostedClient(storedSite.id) + appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${storedSite.url}") + handleValidAuth(storedSite) + return@launch + } + appLogWrapper.d( + AppLog.T.MAIN, + "A_P: Headless mint failed for ${storedSite.url} (notSupported=" + + "${createResult.error?.notSupported})" + ) + + // Step 3: mint failed. If we started with creds, show the reauth banner; otherwise the + // standard "authenticate" card. Either way, discovery is required to populate the URL. + if (hadCreds) { + buildReauthenticationBanner(storedSite) } else { - showReauthenticationCard(site, authorizationUrlComplete) + buildAuthenticationCard(storedSite) } } } - private fun showReauthenticationCard(site: SiteModel, alternativeUrl: String) { - uiModelMutable.postValue( - MySiteCardAndItem.Item.SingleActionCard( - textResource = R.string.application_password_reauthentication_banner, - imageResource = R.drawable.ic_notice_white_24dp, - onActionClick = { onClick(site, alternativeUrl) } - ) - ) - appLogWrapper.d(AppLog.T.MAIN, "A_P: Showing reauthentication card for ${site.url}") + private fun handleValidAuth(site: SiteModel) { + // Only true self-hosted sites need the XML-RPC fallback path — Atomic and Jetpack-WPCom-REST + // sites talk REST end-to-end and don't need XML-RPC. + if (!site.isUsingWpComRestApi && site.xmlRpcUrl.isNullOrEmpty()) { + buildXmlRpcDisabledCard(site) + attemptXmlRpcRediscovery(site) + } else { + uiModelMutable.postValue(null) + appLogWrapper.d(AppLog.T.MAIN, "A_P: Hiding card for ${site.url} - authenticated") + } } - private fun buildApplicationPasswordDiscovery(site: SiteModel) { - scope.launch { - // If the site is already authorized, no need to run the discovery - val storedSite = siteStore.sites.firstOrNull { it.id == site.id } - if (storedSite != null && !applicationPasswordLoginHelper.siteHasBadCredentials(storedSite)) { + private suspend fun buildReauthenticationBanner(site: SiteModel) { + when (val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(site.url)) { + is ApplicationPasswordLoginHelper.DiscoveryResult.Authorized -> { + uiModelMutable.postValue( + MySiteCardAndItem.Item.SingleActionCard( + textResource = R.string.application_password_reauthentication_banner, + imageResource = R.drawable.ic_notice_white_24dp, + onActionClick = { onClick(site, result.authorizationUrl) } + ) + ) + appLogWrapper.d(AppLog.T.MAIN, "A_P: Showing reauthentication card for ${site.url}") + } + is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> { + // TODO follow-up: surface result.userFacingMessage in the card (issue #22884). uiModelMutable.postValue(null) - appLogWrapper.d(AppLog.T.MAIN, "A_P: Hiding card for ${site.url} - authenticated") - return@launch + appLogWrapper.d( + AppLog.T.MAIN, + "A_P: Hiding reauthentication card for ${site.url} - bad discovery: ${result.userFacingMessage}" + ) } + } + } - val authorizationUrlComplete = applicationPasswordLoginHelper.getAuthorizationUrlComplete(site.url) - if (authorizationUrlComplete.isEmpty()) { + private suspend fun buildAuthenticationCard(site: SiteModel) { + when (val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(site.url)) { + is ApplicationPasswordLoginHelper.DiscoveryResult.Authorized -> { + showApplicationPasswordCreateCard(site, result.authorizationUrl) + } + is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> { + // TODO follow-up: surface result.userFacingMessage in the card (issue #22884). uiModelMutable.postValue(null) - appLogWrapper.d(AppLog.T.MAIN, "A_P: Hiding card for ${site.url} - bad discovery") - } else { - showApplicationPasswordCreateCard(site, authorizationUrlComplete) + appLogWrapper.d( + AppLog.T.MAIN, + "A_P: Hiding card for ${site.url} - bad discovery: ${result.userFacingMessage}" + ) } } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelperTest.kt index 82b27d4a7604..b1df5c270090 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelperTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelperTest.kt @@ -4,6 +4,7 @@ import android.content.Context import com.automattic.android.tracks.crashlogging.CrashLogging import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.Mock @@ -345,48 +346,56 @@ class ApplicationPasswordLoginHelperTest : BaseUnitTest() { val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(TEST_URL) - assertEquals("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX", result) + assertEquals( + ApplicationPasswordLoginHelper.DiscoveryResult.Authorized("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX"), + result + ) verify(wpLoginClient).apiDiscovery(eq(TEST_URL)) } @Test - fun `given login scenario, when api discovery fails, then return emtpy`() = runTest { + fun `given login scenario, when api discovery throws, then return Failed`() = runTest { whenever(wpLoginClient.apiDiscovery(eq(TEST_URL))).doThrow(RuntimeException("API discovery failed")) val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(TEST_URL) - assertEquals("", result) + assertTrue(result is ApplicationPasswordLoginHelper.DiscoveryResult.Failed) verify(wpLoginClient).apiDiscovery(eq(TEST_URL)) } @Test - fun `given login scenario, when api discovery is empty, then return empty`() = runTest { + fun `given Success result but auth URL extraction fails, then return Failed`() = runTest { val autoDiscoveryAttemptSuccess = AutoDiscoveryAttemptSuccess( mock(), mock(), mock(), DiscoveredAuthenticationMechanism.ApplicationPasswords(mock()) ) val apiDiscoveryResult = ApiDiscoveryResult.Success(autoDiscoveryAttemptSuccess) whenever(wpLoginClient.apiDiscovery(eq(TEST_URL))).thenReturn(apiDiscoveryResult) + // DiscoverSuccessWrapper.getApplicationPasswordsAuthenticationUrl uses requireNotNull and + // will throw when the mocked authentication graph has no application-passwords URL — that + // throw lands in the catch block in getAuthorizationUrlComplete and surfaces as Failed. + val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(TEST_URL) - assertEquals("", result) + assertTrue(result is ApplicationPasswordLoginHelper.DiscoveryResult.Failed) verify(wpLoginClient).apiDiscovery(eq(TEST_URL)) } @Test - fun `given login scenario, when api discovery is failed, then return empty`() = runTest { - whenever(wpLoginClient.apiDiscovery(eq(TEST_URL))) - .thenReturn( - ApiDiscoveryResult.FailureParseSiteUrl( - ParseUrlException.Generic("") + fun `given login scenario, when api discovery is failed, then return Failed with wordpress-rs message`() = + runTest { + whenever(wpLoginClient.apiDiscovery(eq(TEST_URL))) + .thenReturn( + ApiDiscoveryResult.FailureParseSiteUrl( + ParseUrlException.Generic("") + ) ) - ) - val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(TEST_URL) + val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(TEST_URL) - assertEquals("", result) - verify(wpLoginClient).apiDiscovery(eq(TEST_URL)) - } + assertTrue(result is ApplicationPasswordLoginHelper.DiscoveryResult.Failed) + verify(wpLoginClient).apiDiscovery(eq(TEST_URL)) + } @Test fun `maskUrl with no dot returns url unmasked`() { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModelTest.kt index 712501840d45..80e7033b834f 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModelTest.kt @@ -68,7 +68,7 @@ class ApplicationPasswordAutoAuthDialogViewModelTest : BaseUnitTest() { @Test fun `createApplicationPassword with exception during API call falls back to manual login`() = runTest { whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(testSite.url)) - .thenReturn(testAuthUrl) + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Authorized(testAuthUrl)) val testException = RuntimeException("API client creation failed") whenever(wpApiClientProvider.getWpApiClientCookiesNonceAuthentication(eq(testSite))) .doThrow(testException) @@ -107,7 +107,7 @@ class ApplicationPasswordAutoAuthDialogViewModelTest : BaseUnitTest() { password = "testpass123" } whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(invalidSite.url)) - .thenReturn(testAuthUrl) + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Authorized(testAuthUrl)) viewModel.navigationEvent.test { viewModel.createApplicationPassword(invalidSite, ApplicationPasswordCreationTracker.SOURCE_AUTO_MIGRATION) @@ -133,7 +133,7 @@ class ApplicationPasswordAutoAuthDialogViewModelTest : BaseUnitTest() { password = "" } whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(invalidSite.url)) - .thenReturn(testAuthUrl) + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Authorized(testAuthUrl)) viewModel.navigationEvent.test { viewModel.createApplicationPassword(invalidSite, ApplicationPasswordCreationTracker.SOURCE_AUTO_MIGRATION) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModelTest.kt index fba5de042f99..716a51acdee5 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModelTest.kt @@ -47,7 +47,7 @@ class ApplicationPasswordDialogViewModelTest : BaseUnitTest() { fun `onDialogConfirmed with valid URL processes successfully and emits NavigateToLogin`() = runTest { // Given whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(testAuthUrl))) - .thenReturn(testCompleteAuthUrl) + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Authorized(testCompleteAuthUrl)) // When & Then viewModel.navigationEvent.test { @@ -103,7 +103,7 @@ class ApplicationPasswordDialogViewModelTest : BaseUnitTest() { fun `onDialogConfirmed with helper returning empty URL emits ShowError`() = runTest { // Given whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(testAuthUrl))) - .thenReturn("") + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Failed("test failure")) // When & Then viewModel.navigationEvent.test { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt index 5c8944821a46..45d7aa7ef4b4 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt @@ -40,7 +40,7 @@ class LoginSiteApplicationPasswordViewModelTest : BaseUnitTest() { val siteUrl = "https.example.com" val expectedDiscoveryUrl = "https://example.com/wp-json/wp/v2/application-passwords/authorization" whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)) - .thenReturn(expectedDiscoveryUrl) + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Authorized(expectedDiscoveryUrl)) // A collector for the discoveryURL SharedFlow var collectedUrl: String? = null diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt index e1ea600f6a5a..42d0adaef023 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt @@ -20,10 +20,14 @@ import org.mockito.kotlin.mock import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.SitesModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCredentials import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.fluxc.store.SiteStore.OnApplicationPasswordCreated import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper import org.wordpress.android.ui.mysite.MySiteCardAndItem @@ -51,6 +55,9 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { @Mock lateinit var wpApiClientProvider: WpApiClientProvider + @Mock + lateinit var applicationPasswordValidator: ApplicationPasswordValidator + @Mock lateinit var selfHostedEndpointFinder: SelfHostedEndpointFinder @@ -75,6 +82,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { siteStore, appLogWrapper, wpApiClientProvider, + applicationPasswordValidator, selfHostedEndpointFinder, siteXMLRPCClient, dispatcher, @@ -90,18 +98,47 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { siteId = TEST_SITE_ID.toLong() apiRestUsernamePlain = "testuser" apiRestPasswordPlain = "testpass" + // Mark xmlRpcUrl so handleValidAuth's XML-RPC-disabled fallback doesn't fire — that + // path is exercised by the dedicated xmlRpcRediscovery tests below. + xmlRpcUrl = "https://www.test.com/xmlrpc.php" } applicationPasswordCard = null applicationPasswordViewModelSlice.uiModel.observeForever { card -> applicationPasswordCard = card } + + // By default, treat the site as having no stored credentials. Tests that exercise the + // validate-stored-creds path override this. + whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(true) + } + + private suspend fun stubMintFailure(notSupported: Boolean = false) { + whenever(siteStore.createApplicationPassword(any())).thenReturn( + OnApplicationPasswordCreated( + siteTest, + BaseNetworkError(GenericErrorType.UNKNOWN, "fail"), + notSupported = notSupported, + ) + ) + } + + private suspend fun stubMintSuccess() { + whenever(siteStore.createApplicationPassword(any())).thenReturn( + OnApplicationPasswordCreated( + siteTest, + ApplicationPasswordCredentials("user", "pass", uuid = "u") + ) + ) } @Test fun `given proper site, when api discovery is success, then add the application password card`() = runTest { + stubMintFailure() whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) - .thenReturn("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX") + .thenReturn( + ApplicationPasswordLoginHelper.DiscoveryResult.Authorized("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX") + ) applicationPasswordViewModelSlice.buildCard(siteTest) @@ -111,35 +148,147 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { @Test fun `given login scenario, when api discovery is empty, then show no card`() = runTest { + stubMintFailure() whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) - .thenReturn("") + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Failed("test discovery failure")) + + applicationPasswordViewModelSlice.buildCard(siteTest) + + assertNull(applicationPasswordCard) + verify(applicationPasswordLoginHelper).getAuthorizationUrlComplete(eq(TEST_URL)) + } + + @Test + fun `given headless mint succeeds, then hide card and skip discovery`() = runTest { + stubMintSuccess() applicationPasswordViewModelSlice.buildCard(siteTest) assertNull(applicationPasswordCard) + verify(siteStore).createApplicationPassword(any()) + verify(applicationPasswordLoginHelper, never()).getAuthorizationUrlComplete(any()) + } + + @Test + fun `given headless mint returns NotSupported, then fall back to discovery`() = runTest { + stubMintFailure(notSupported = true) + whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) + .thenReturn( + ApplicationPasswordLoginHelper.DiscoveryResult.Authorized("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX") + ) + + applicationPasswordViewModelSlice.buildCard(siteTest) + + assertNotNull(applicationPasswordCard) + verify(siteStore).createApplicationPassword(any()) verify(applicationPasswordLoginHelper).getAuthorizationUrlComplete(eq(TEST_URL)) } @Test - fun `given site already authenticated, when calling api discovery, then show no card`() = runTest { - whenever(siteStore.sites) - .thenReturn( + fun `given site already authenticated and validation succeeds, then show no card`() = runTest { + whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) + whenever(siteStore.sites).thenReturn( listOf( SiteModel().apply { id = siteTest.id + url = TEST_URL apiRestUsernamePlain = "user" apiRestPasswordPlain = "password" + xmlRpcUrl = siteTest.xmlRpcUrl } ) ) + whenever(applicationPasswordValidator.validate(any())) + .thenReturn(ApplicationPasswordValidator.Outcome.Valid) applicationPasswordViewModelSlice.buildCard(siteTest) assertNull(applicationPasswordCard) - verify(siteStore).sites + verify(applicationPasswordValidator).validate(any()) + verify(siteStore, never()).createApplicationPassword(any()) verify(applicationPasswordLoginHelper, times(0)).getAuthorizationUrlComplete(any()) } + @Test + fun `given stored creds invalid, clear them and try headless mint`() = runTest { + whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) + whenever(siteStore.sites).thenReturn( + listOf( + SiteModel().apply { + id = siteTest.id + url = TEST_URL + apiRestUsernamePlain = "stale-user" + apiRestPasswordPlain = "stale-pass" + xmlRpcUrl = siteTest.xmlRpcUrl + } + ) + ) + whenever(applicationPasswordValidator.validate(any())) + .thenReturn(ApplicationPasswordValidator.Outcome.Invalid) + stubMintSuccess() + + applicationPasswordViewModelSlice.buildCard(siteTest) + + assertNull(applicationPasswordCard) + verify(siteStore).deleteStoredApplicationPasswordCredentials(any()) + // clearSelfHostedClient is invoked twice — once on invalidation, once after the fresh mint + verify(wpApiClientProvider, times(2)).clearSelfHostedClient(siteTest.id) + verify(siteStore).createApplicationPassword(any()) + } + + @Test + fun `given stored creds invalid and mint fails, show reauthentication banner`() = runTest { + whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) + whenever(siteStore.sites).thenReturn( + listOf( + SiteModel().apply { + id = siteTest.id + url = TEST_URL + apiRestUsernamePlain = "stale-user" + apiRestPasswordPlain = "stale-pass" + xmlRpcUrl = siteTest.xmlRpcUrl + } + ) + ) + whenever(applicationPasswordValidator.validate(any())) + .thenReturn(ApplicationPasswordValidator.Outcome.Invalid) + stubMintFailure(notSupported = true) + whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) + .thenReturn( + ApplicationPasswordLoginHelper.DiscoveryResult.Authorized("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX") + ) + + applicationPasswordViewModelSlice.buildCard(siteTest) + + assertNotNull(applicationPasswordCard) + // Reauth banner uses SingleActionCard (not the QuickLinksItem create card) + assert(applicationPasswordCard is MySiteCardAndItem.Item.SingleActionCard) + } + + @Test + fun `given validation hits a network error, leave the card hidden without re-minting`() = runTest { + whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) + whenever(siteStore.sites).thenReturn( + listOf( + SiteModel().apply { + id = siteTest.id + url = TEST_URL + apiRestUsernamePlain = "user" + apiRestPasswordPlain = "pass" + xmlRpcUrl = siteTest.xmlRpcUrl + } + ) + ) + whenever(applicationPasswordValidator.validate(any())) + .thenReturn(ApplicationPasswordValidator.Outcome.NetworkUnavailable) + + applicationPasswordViewModelSlice.buildCard(siteTest) + + assertNull(applicationPasswordCard) + verify(siteStore, never()).createApplicationPassword(any()) + verify(siteStore, never()).deleteStoredApplicationPasswordCredentials(any()) + } + @Test fun `given xmlRpc rediscovery and auth check succeed, then update site and dispatch`() = runTest { @@ -164,6 +313,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { @Test fun `given xmlRpc rediscovery succeeds but auth check fails, then do not dispatch`() = runTest { + siteTest.xmlRpcUrl = null val xmlRpcUrl = "https://www.test.com/xmlrpc.php" whenever( selfHostedEndpointFinder @@ -188,6 +338,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { @Test fun `given xmlRpc rediscovery fails, then do not dispatch`() = runTest { + siteTest.xmlRpcUrl = null whenever( selfHostedEndpointFinder .verifyOrDiscoverXMLRPCEndpoint(TEST_URL) diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsManager.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsManager.kt index eaa7c81a6d68..ea38e1403a6a 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsManager.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsManager.kt @@ -42,7 +42,7 @@ internal class ApplicationPasswordsManager @Inject constructor( suspend fun getApplicationCredentials( site: SiteModel ): ApplicationPasswordCreationResult { - if (site.isWPCom) return ApplicationPasswordCreationResult.NotSupported( + if (site.isWPComSimpleSite) return ApplicationPasswordCreationResult.NotSupported( WPAPINetworkError( BaseNetworkError( GenericErrorType.UNKNOWN, diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteStore.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteStore.kt index 0ca89717a73b..de9ecba91a4c 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteStore.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteStore.kt @@ -83,9 +83,13 @@ import org.wordpress.android.fluxc.model.asDomainModel import org.wordpress.android.fluxc.model.jetpacksocial.JetpackSocial import org.wordpress.android.fluxc.model.jetpacksocial.JetpackSocialMapper import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCreationResult +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCredentials import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordDeletionResult +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsConfiguration import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsManager import org.wordpress.android.fluxc.network.rest.wpapi.site.SiteWPAPIRestClient import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError @@ -161,6 +165,7 @@ open class SiteStore @Inject constructor( private val coroutineEngine: CoroutineEngine ) : Store(dispatcher) { @Inject internal lateinit var applicationPasswordsManagerProvider: Provider + @Inject internal lateinit var applicationPasswordsConfigurationProvider: Provider // Payloads data class CompleteQuickStartPayload( @@ -798,6 +803,15 @@ open class SiteStore @Inject constructor( } } + data class OnApplicationPasswordCreated( + val site: SiteModel, + val credentials: ApplicationPasswordCredentials? = null, + ) : OnChanged() { + constructor(site: SiteModel, error: BaseNetworkError, notSupported: Boolean): this(site) { + this.error = OnApplicationPasswordCreateError(error, notSupported) + } + } + class OnSiteLaunched() : OnChanged() { constructor(error: LaunchSiteError) : this() { this.error = error @@ -829,6 +843,23 @@ open class SiteStore @Inject constructor( } } + class OnApplicationPasswordCreateError( + error: BaseNetworkError, + val notSupported: Boolean, + ) : OnChangedError { + var errorCode: String? = null + var message: String + + init { + if (error is WPAPINetworkError) { + errorCode = error.errorCode + } else if (error is WPComGsonNetworkError) { + errorCode = error.apiError + } + message = error.message + } + } + class PlansError @JvmOverloads constructor( @JvmField val type: PlansErrorType, @@ -2372,6 +2403,67 @@ open class SiteStore @Inject constructor( } } + suspend fun createApplicationPassword(site: SiteModel): OnApplicationPasswordCreated = + coroutineEngine.withDefaultContext(T.API, this, "Create Application Password") { + if (!applicationPasswordsConfigurationProvider.get().isEnabled) { + return@withDefaultContext OnApplicationPasswordCreated( + site, + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Application Passwords feature is not configured for this app" + ), + notSupported = true, + ) + } + when (val result = applicationPasswordsManagerProvider.get().getApplicationCredentials(site)) { + is ApplicationPasswordCreationResult.Created -> { + persistApplicationPasswordCredentials(site, result.credentials) + OnApplicationPasswordCreated(site, result.credentials) + } + is ApplicationPasswordCreationResult.Existing -> { + if (site.apiRestUsernamePlain.isNullOrEmpty() || site.apiRestPasswordPlain.isNullOrEmpty()) { + persistApplicationPasswordCredentials(site, result.credentials) + } + OnApplicationPasswordCreated(site, result.credentials) + } + is ApplicationPasswordCreationResult.NotSupported -> + OnApplicationPasswordCreated(site, result.originalError, notSupported = true) + is ApplicationPasswordCreationResult.Failure -> + OnApplicationPasswordCreated(site, result.error, notSupported = false) + } + } + + /** + * Clears stored application-password credentials (encrypted prefs) so the next call to + * [createApplicationPassword] mints a fresh one. Use this when a validation call against the + * stored credentials has failed (e.g. 401, password revoked server-side). + */ + fun deleteStoredApplicationPasswordCredentials(site: SiteModel) { + applicationPasswordsManagerProvider.get().deleteLocalApplicationPassword(site) + } + + private fun persistApplicationPasswordCredentials( + site: SiteModel, + credentials: ApplicationPasswordCredentials, + ) { + // Despite the "Plain" suffix, these fields are transient in-memory only — they have no + // `@Column` annotation. `SiteSqlUtils.insertOrUpdateSite` runs them through + // `encryptAPIRestCredentials` (AES/GCM/NoPadding, key in AndroidKeyStore) and persists + // ciphertext + IV into the *Encrypted columns. We clear the encrypted columns here so + // re-mints re-encrypt from the fresh plain values — `encryptAPIRestCredentials` + // short-circuits if the encrypted columns are already populated and would otherwise leave + // the stale ciphertext in place. + site.apply { + apiRestUsernamePlain = credentials.userName + apiRestPasswordPlain = credentials.password + apiRestUsernameEncrypted = "" + apiRestPasswordEncrypted = "" + apiRestUsernameIV = "" + apiRestPasswordIV = "" + } + emitChange(updateApplicationPassword(site)) + } + suspend fun fetchSitePlans(siteModel: SiteModel): FetchedPlansPayload { return if (siteModel.isUsingWpComRestApi) { coroutineEngine.withDefaultContext(T.API, this, "Fetch site plans") { diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordManagerTests.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordManagerTests.kt index 2daae86ac14e..99c91d22991f 100644 --- a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordManagerTests.kt +++ b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordManagerTests.kt @@ -49,6 +49,45 @@ class ApplicationPasswordManagerTests { ) } + @Test + fun `given a simple WPCom site, when we ask for a password, then return NotSupported`() = runTest { + val site = SiteModel().apply { + url = "http://simple.wordpress.com" + setIsWPCom(true) + setIsWPComAtomic(false) + } + + val result = mApplicationPasswordsManager.getApplicationCredentials(site) + + Assert.assertTrue(result is ApplicationPasswordCreationResult.NotSupported) + } + + @Test + fun `given an Atomic site, when we ask for a password, then create it via the Jetpack client`() = runTest { + val site = SiteModel().apply { + url = "http://atomic.example.com" + username = "username" + setIsWPCom(true) + setIsWPComAtomic(true) + origin = SiteModel.ORIGIN_WPCOM_REST + } + + whenever(applicationPasswordsStore.getCredentials(site)).thenReturn(null) + whenever(mJetpackApplicationPasswordsRestClient.fetchWPAdminUsername(site)) + .thenReturn(UsernameFetchPayload(testCredentials.userName)) + whenever( + mJetpackApplicationPasswordsRestClient.createApplicationPassword(site, applicationName) + ).thenReturn( + ApplicationPasswordCreationPayload(testCredentials.password, testCredentials.uuid!!) + ) + + val result = mApplicationPasswordsManager.getApplicationCredentials(site) + + assertEquals(ApplicationPasswordCreationResult.Created(testCredentials), result) + verify(mJetpackApplicationPasswordsRestClient).createApplicationPassword(site, applicationName) + verify(applicationPasswordsStore).saveCredentials(site, testCredentials) + } + @Test fun `given a local password exists, when we ask for a password, then return it`() = runTest { whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(testCredentials) diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/store/OnApplicationPasswordCreateErrorTest.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/store/OnApplicationPasswordCreateErrorTest.kt new file mode 100644 index 000000000000..85595faf020b --- /dev/null +++ b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/store/OnApplicationPasswordCreateErrorTest.kt @@ -0,0 +1,49 @@ +package org.wordpress.android.fluxc.store + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.store.SiteStore.OnApplicationPasswordCreateError + +class OnApplicationPasswordCreateErrorTest { + @Test + fun `given a WPAPINetworkError, then errorCode comes from the WP-API error code`() { + val base = BaseNetworkError(GenericErrorType.SERVER_ERROR, "wp-api error message") + val wpApiError = WPAPINetworkError(base, "rest_no_route") + + val error = OnApplicationPasswordCreateError(wpApiError, notSupported = false) + + assertThat(error.errorCode).isEqualTo("rest_no_route") + assertThat(error.message).isEqualTo("wp-api error message") + assertThat(error.notSupported).isFalse() + } + + @Test + fun `given a WPComGsonNetworkError, then errorCode comes from apiError`() { + val gsonError = WPComGsonNetworkError( + BaseNetworkError(GenericErrorType.SERVER_ERROR, "wp.com error message") + ).apply { + apiError = "application_passwords_disabled" + } + + val error = OnApplicationPasswordCreateError(gsonError, notSupported = true) + + assertThat(error.errorCode).isEqualTo("application_passwords_disabled") + assertThat(error.message).isEqualTo("wp.com error message") + assertThat(error.notSupported).isTrue() + } + + @Test + fun `given a plain BaseNetworkError, then errorCode is null and message is captured`() { + val base = BaseNetworkError(GenericErrorType.NETWORK_ERROR, "offline") + + val error = OnApplicationPasswordCreateError(base, notSupported = false) + + assertThat(error.errorCode).isNull() + assertThat(error.message).isEqualTo("offline") + assertThat(error.notSupported).isFalse() + } +}