Skip to content

Commit bab2b77

Browse files
committed
Carry minted credentials to the capability probe as a value
On a first provision of an Atomic site, the My Site screen showed a false "Unable to connect to your site" banner. `ensureAuth` minted an application password, but `detectCapabilities` re-read the `SiteModel` fresh and a concurrent whole-row site write (`insertOrUpdateSite` uses `UpdateAllExceptId`) had clobbered the just-encrypted credential columns before that read (#22905). With no credentials present, the authenticated direct-host probe was skipped, the unauthenticated discovery failed against the private host, and the probe reported the site unreachable. `ensureAuth` now returns the credentials it obtained in an internal `AuthResult`, and `detectCapabilities` overlays that immutable value onto its own coroutine-local `SiteModel` copy. The stages still read fresh and write only their own column — no `SiteModel` is shared or mutated across the parallel detect/`recoverXmlRpc` branches, so there's no race. Verified on-device against a private Atomic site: the authenticated direct-host probe runs and the banner is gone.
1 parent 166309b commit bab2b77

2 files changed

Lines changed: 104 additions & 24 deletions

File tree

WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -138,45 +138,55 @@ class SiteProvisioningSource @Inject constructor(
138138
private fun shouldRun(siteLocalId: Int): Boolean =
139139
jobs[siteLocalId]?.isActive != true && siteLocalId !in ready
140140

141-
private suspend fun runPipeline(siteLocalId: Int): SiteReadiness =
142-
when (val auth = ensureAuth(siteLocalId)) {
141+
private suspend fun runPipeline(siteLocalId: Int): SiteReadiness {
142+
val auth = ensureAuth(siteLocalId)
143+
return when (auth.state) {
143144
SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> coroutineScope {
144-
// Post-auth, the REST-capability chain and the XML-RPC recovery are independent —
145-
// each reads the site fresh and writes only its own column — so run them in
146-
// parallel. recoverRestUrlIfNeeded precedes detectCapabilities within its branch because
147-
// the probe needs the recovered REST root.
148-
val capabilities = async { recoverRestUrlIfNeeded(siteLocalId); detectCapabilities(siteLocalId) }
145+
// Post-auth, the REST-capability chain and the XML-RPC recovery are independent — each
146+
// reads the site fresh and writes only its own column — so run them in parallel.
147+
// recoverRestUrlIfNeeded precedes detectCapabilities within its branch because the probe
148+
// needs the recovered REST root. The mint's credentials reach the probe as an immutable
149+
// value (auth.credentials), never via a shared mutated SiteModel — the fresh-read columns
150+
// can't be trusted to carry them (transient, and clobberable by a concurrent write, #22905).
151+
val capabilities = async {
152+
recoverRestUrlIfNeeded(siteLocalId)
153+
detectCapabilities(siteLocalId, auth.credentials)
154+
}
149155
val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) }
150156
xmlRpc.await()
151157
capabilities.await()
152158
}
153-
else -> SiteReadiness.NeedsAuth(auth)
159+
else -> SiteReadiness.NeedsAuth(auth.state)
154160
}
161+
}
155162

156163
/**
157-
* Stage 1 — ensure the site has working application-password credentials.
158-
* Validates stored creds with Basic auth against the direct host; on a
159-
* confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack
160-
* tunnel. The mint persists the credentials, so later stages read them back.
164+
* Stage 1 — ensure the site has working application-password credentials, and
165+
* return them. Validates stored creds with Basic auth against the direct host;
166+
* on a confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack
167+
* tunnel. The credentials are returned in the [AuthResult] so [detectCapabilities]
168+
* can authenticate with them without trusting a fresh re-read — the plain columns
169+
* are transient and a concurrent whole-row site write can clobber the encrypted
170+
* ones mid-run (#22905).
161171
*/
162172
// Each return is a distinct auth outcome (missing site, valid, transient, minted, failed);
163173
// collapsing to one return would thread a result through nested branches and read worse.
164174
@Suppress("ReturnCount")
165-
private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState {
175+
private suspend fun ensureAuth(siteLocalId: Int): AuthResult {
166176
val site = siteStore.getSiteByLocalId(siteLocalId)
167-
?: return SiteAuthState.Unprovisionable(hadCredentials = false)
177+
?: return AuthResult(SiteAuthState.Unprovisionable(hadCredentials = false))
168178
// WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password
169179
// applies (the mint returns NotSupported). Capability detection works through the proxy, so
170180
// treat them as ready instead of blocking detection behind a mint that can never run.
171-
if (site.isWPComSimpleSite) return SiteAuthState.NotApplicable
181+
if (site.isWPComSimpleSite) return AuthResult(SiteAuthState.NotApplicable)
172182
val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site)
173183
if (hadCredentials) {
174184
when (applicationPasswordValidator.validate(site)) {
175185
ApplicationPasswordValidator.Outcome.Valid ->
176-
return SiteAuthState.Provisioned
186+
return AuthResult(SiteAuthState.Provisioned, site.provisionedCredentials())
177187
ApplicationPasswordValidator.Outcome.NetworkUnavailable -> {
178188
appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${site.url}")
179-
return SiteAuthState.Provisioning
189+
return AuthResult(SiteAuthState.Provisioning)
180190
}
181191
ApplicationPasswordValidator.Outcome.Invalid -> {
182192
appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing")
@@ -185,23 +195,24 @@ class SiteProvisioningSource @Inject constructor(
185195
}
186196
}
187197
}
198+
// createApplicationPassword mutates this local `site` with the freshly minted plain credentials.
188199
val createResult = siteStore.createApplicationPassword(site)
189200
if (!createResult.isError && createResult.credentials != null) {
190201
wpApiClientProvider.clearSelfHostedClient(site.id)
191202
appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${site.url}")
192-
return SiteAuthState.Provisioned
203+
return AuthResult(SiteAuthState.Provisioned, site.provisionedCredentials())
193204
}
194205
appLogWrapper.d(
195206
AppLog.T.MAIN,
196207
"A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported})"
197208
)
198-
return SiteAuthState.Unprovisionable(hadCredentials = hadCredentials)
209+
return AuthResult(SiteAuthState.Unprovisionable(hadCredentials = hadCredentials))
199210
}
200211

201212
/**
202213
* Stage 2a — recover the REST API root for Atomic sites minted through the
203214
* Jetpack tunnel (which never runs discovery and leaves `wpApiRestUrl` null).
204-
* Persists the one column; the capability probe re-reads it.
215+
* Persists the one column; the capability probe (sequenced after it) re-reads it.
205216
*/
206217
private suspend fun recoverRestUrlIfNeeded(siteLocalId: Int) {
207218
val site = siteStore.getSiteByLocalId(siteLocalId) ?: return
@@ -229,12 +240,25 @@ class SiteProvisioningSource @Inject constructor(
229240

230241
/**
231242
* Stage 3 — probe the REST API for editor-capability support and persist it.
232-
* Reached only once auth is [SiteAuthState.Provisioned], so credentials are
233-
* guaranteed present: a failure here is a real transport problem, not a
234-
* pending mint.
243+
* Reached only once auth is [SiteAuthState.Provisioned] / [SiteAuthState.NotApplicable].
244+
* [credentials] (from [ensureAuth]) are overlaid onto this run-local copy so the
245+
* authenticated direct-host probe can run even when the fresh read doesn't reflect
246+
* the mint; a failure here is then a real transport problem, not a pending mint.
235247
*/
236-
private suspend fun detectCapabilities(siteLocalId: Int): SiteReadiness {
248+
private suspend fun detectCapabilities(
249+
siteLocalId: Int,
250+
credentials: ProvisionedCredentials?,
251+
): SiteReadiness {
237252
val site = siteStore.getSiteByLocalId(siteLocalId) ?: return SiteReadiness.Unreachable
253+
// Overlay the credentials ensureAuth obtained. The fresh read can't be trusted to carry them:
254+
// the plain columns are transient (never persisted), and a concurrent whole-row site write
255+
// during My Site load can clobber the encrypted ones before this read (#22905). This copy is
256+
// local to this coroutine — not shared with the parallel XML-RPC stage — so the overlay is
257+
// race-free.
258+
credentials?.let {
259+
if (site.apiRestUsernamePlain.isNullOrEmpty()) site.apiRestUsernamePlain = it.username
260+
if (site.apiRestPasswordPlain.isNullOrEmpty()) site.apiRestPasswordPlain = it.password
261+
}
238262
val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
239263
val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
240264
return when {
@@ -245,6 +269,26 @@ class SiteProvisioningSource @Inject constructor(
245269
}
246270
}
247271

272+
/**
273+
* Snapshot of the application-password credentials [SiteProvisioningSource.ensureAuth] obtained,
274+
* handed forward to the capability probe as an immutable value rather than via a shared, mutated
275+
* [SiteModel] — so the parallel stages never race on it.
276+
*/
277+
private data class ProvisionedCredentials(val username: String, val password: String)
278+
279+
private fun SiteModel.provisionedCredentials(): ProvisionedCredentials? {
280+
val user = apiRestUsernamePlain
281+
val pass = apiRestPasswordPlain
282+
return if (!user.isNullOrEmpty() && !pass.isNullOrEmpty()) ProvisionedCredentials(user, pass) else null
283+
}
284+
285+
/**
286+
* [SiteProvisioningSource.ensureAuth]'s outcome: the public [SiteAuthState] plus, when provisioned,
287+
* the credentials to hand to the capability probe. Internal so the credentials never leak into the
288+
* UI-facing [SiteReadiness] / [SiteAuthState].
289+
*/
290+
private data class AuthResult(val state: SiteAuthState, val credentials: ProvisionedCredentials? = null)
291+
248292
/**
249293
* Whether a site's application password is usable. Owned by [SiteProvisioningSource];
250294
* rendered by the application-password card.

WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import org.junit.Before
77
import org.junit.Test
88
import org.mockito.Mock
99
import org.mockito.kotlin.any
10+
import org.mockito.kotlin.argThat
1011
import org.mockito.kotlin.eq
1112
import org.mockito.kotlin.never
1213
import org.mockito.kotlin.times
@@ -91,6 +92,14 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) {
9192
if (!ok) whenever(editorSettingsRepository.hasCachedCapabilities(any())).thenReturn(cached)
9293
}
9394

95+
private fun provisionableSiteCopy() = SiteModel().apply {
96+
id = TEST_SITE_LOCAL_ID
97+
url = "https://test.example.com"
98+
// Non-null REST root + XML-RPC url so both recovery branches short-circuit.
99+
wpApiRestUrl = "https://test.example.com/wp-json"
100+
xmlRpcUrl = "https://test.example.com/xmlrpc.php"
101+
}
102+
94103
// region auth stage
95104

96105
@Test
@@ -174,6 +183,33 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) {
174183
verify(siteStore, never()).createApplicationPassword(any())
175184
}
176185

186+
@Test
187+
fun `carries minted credentials to the probe even when the re-read lacks them`() = test {
188+
// ensureAuth reads one copy and the mint sets credentials on it; detectCapabilities re-reads a
189+
// SEPARATE copy that — simulating a concurrent whole-row clobber (#22905) — carries none. The
190+
// probe must still receive a credentialed site, because the pipeline hands the mint's creds
191+
// forward as an immutable value rather than relying on the re-read or a shared, race-prone model.
192+
val authCopy = provisionableSiteCopy()
193+
val credentiallessReread = provisionableSiteCopy()
194+
whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(authCopy, credentiallessReread)
195+
stubHasStoredCredentials(false)
196+
whenever(siteStore.createApplicationPassword(any())).thenAnswer { invocation ->
197+
// The real createApplicationPassword sets the plain credentials on the passed site.
198+
(invocation.arguments[0] as SiteModel).apply {
199+
apiRestUsernamePlain = "user"
200+
apiRestPasswordPlain = "pass"
201+
}
202+
OnApplicationPasswordCreated(authCopy, ApplicationPasswordCredentials("user", "pass", uuid = "u"))
203+
}
204+
stubCapabilityProbe(ok = true)
205+
206+
source.await(site)
207+
208+
verify(editorSettingsRepository).fetchEditorCapabilitiesForSite(
209+
argThat { apiRestUsernamePlain == "user" && apiRestPasswordPlain == "pass" }
210+
)
211+
}
212+
177213
// endregion
178214

179215
// region recovery + capability stages

0 commit comments

Comments
 (0)