Skip to content

Commit 3f20dc4

Browse files
committed
Drop credential forwarding now that app-password columns are single-writer
#22947 made the app-password columns single-writer — excluded from the generic full-row update, written only by updateApplicationPasswordCredentials — so a fresh getSiteByLocalId after a mint reliably carries the credentials and a concurrent site write can't clobber them (#22905). The pipeline no longer needs to thread them as a value: remove ProvisionedCredentials, AuthResult.credentials, and the detectCapabilities overlay. ensureAuth returns a bare SiteAuthState; detectCapabilities and recoverXmlRpcIfNeeded read the credentials off the fresh read.
1 parent 9b38f59 commit 3f20dc4

2 files changed

Lines changed: 26 additions & 96 deletions

File tree

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

Lines changed: 26 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -140,53 +140,49 @@ class SiteProvisioningSource @Inject constructor(
140140

141141
private suspend fun runPipeline(siteLocalId: Int): SiteReadiness {
142142
val auth = ensureAuth(siteLocalId)
143-
return when (auth.state) {
143+
return when (auth) {
144144
SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> coroutineScope {
145145
// Post-auth, the REST-capability chain and the XML-RPC recovery are independent — each
146146
// reads the site fresh and writes only its own column — so run them in parallel.
147147
// 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).
148+
// needs the recovered REST root. Both branches read the credentials fresh from the store:
149+
// the mint persists them via a single writer that the generic full-row update can no
150+
// longer clobber (#22947), so the re-read is now trustworthy (#22905).
151151
val capabilities = async {
152152
recoverRestUrlIfNeeded(siteLocalId)
153-
detectCapabilities(siteLocalId, auth.credentials)
153+
detectCapabilities(siteLocalId)
154154
}
155155
val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) }
156156
xmlRpc.await()
157157
capabilities.await()
158158
}
159-
else -> SiteReadiness.NeedsAuth(auth.state)
159+
else -> SiteReadiness.NeedsAuth(auth)
160160
}
161161
}
162162

163163
/**
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).
164+
* Stage 1 — ensure the site has working application-password credentials. Validates stored creds
165+
* with Basic auth against the direct host; on a confirmed rejection wipes them and mints fresh
166+
* ones via the FluxC Jetpack tunnel. The mint persists the credentials (single-writer, #22947), so
167+
* the downstream stages read them back from a fresh [SiteModel] rather than having them threaded.
171168
*/
172169
// Each return is a distinct auth outcome (missing site, valid, transient, minted, failed);
173170
// collapsing to one return would thread a result through nested branches and read worse.
174171
@Suppress("ReturnCount")
175-
private suspend fun ensureAuth(siteLocalId: Int): AuthResult {
172+
private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState {
176173
val site = siteStore.getSiteByLocalId(siteLocalId)
177-
?: return AuthResult(SiteAuthState.Unprovisionable(hadCredentials = false))
174+
?: return SiteAuthState.Unprovisionable(hadCredentials = false)
178175
// WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password
179176
// applies (the mint returns NotSupported). Capability detection works through the proxy, so
180177
// treat them as ready instead of blocking detection behind a mint that can never run.
181-
if (site.isWPComSimpleSite) return AuthResult(SiteAuthState.NotApplicable)
178+
if (site.isWPComSimpleSite) return SiteAuthState.NotApplicable
182179
val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site)
183180
if (hadCredentials) {
184181
when (applicationPasswordValidator.validate(site)) {
185-
ApplicationPasswordValidator.Outcome.Valid ->
186-
return AuthResult(SiteAuthState.Provisioned, site.provisionedCredentials())
182+
ApplicationPasswordValidator.Outcome.Valid -> return SiteAuthState.Provisioned
187183
ApplicationPasswordValidator.Outcome.NetworkUnavailable -> {
188184
appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${site.url}")
189-
return AuthResult(SiteAuthState.Provisioning)
185+
return SiteAuthState.Provisioning
190186
}
191187
ApplicationPasswordValidator.Outcome.Invalid -> {
192188
appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing")
@@ -195,18 +191,19 @@ class SiteProvisioningSource @Inject constructor(
195191
}
196192
}
197193
}
198-
// createApplicationPassword mutates this local `site` with the freshly minted plain credentials.
194+
// createApplicationPassword mints and persists the credentials; downstream stages read them
195+
// back from a fresh SiteModel.
199196
val createResult = siteStore.createApplicationPassword(site)
200197
if (!createResult.isError && createResult.credentials != null) {
201198
wpApiClientProvider.clearSelfHostedClient(site.id)
202199
appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${site.url}")
203-
return AuthResult(SiteAuthState.Provisioned, site.provisionedCredentials())
200+
return SiteAuthState.Provisioned
204201
}
205202
appLogWrapper.d(
206203
AppLog.T.MAIN,
207204
"A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported})"
208205
)
209-
return AuthResult(SiteAuthState.Unprovisionable(hadCredentials = hadCredentials))
206+
return SiteAuthState.Unprovisionable(hadCredentials = hadCredentials)
210207
}
211208

212209
/**
@@ -226,8 +223,10 @@ class SiteProvisioningSource @Inject constructor(
226223

227224
/**
228225
* Stage 2b (parallel) — recover the XML-RPC endpoint for true self-hosted
229-
* sites that don't have one. Discovers + authenticates against it, and on
230-
* success persists the one column; the application-password card re-reads it.
226+
* sites that don't have one. Discovers + authenticates against it with the
227+
* site's application-password credentials (which work for XML-RPC just as for
228+
* REST), and on success persists the one column; the application-password card
229+
* re-reads it.
231230
*/
232231
private suspend fun recoverXmlRpcIfNeeded(siteLocalId: Int) {
233232
val site = siteStore.getSiteByLocalId(siteLocalId) ?: return
@@ -241,24 +240,11 @@ class SiteProvisioningSource @Inject constructor(
241240
/**
242241
* Stage 3 — probe the REST API for editor-capability support and persist it.
243242
* 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.
243+
* Reads the site fresh: the mint has already persisted the credentials (single-writer, #22947),
244+
* so a probe failure here is a real transport problem, not a pending mint.
247245
*/
248-
private suspend fun detectCapabilities(
249-
siteLocalId: Int,
250-
credentials: ProvisionedCredentials?,
251-
): SiteReadiness {
246+
private suspend fun detectCapabilities(siteLocalId: Int): SiteReadiness {
252247
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-
}
262248
val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
263249
val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
264250
return when {
@@ -269,26 +255,6 @@ class SiteProvisioningSource @Inject constructor(
269255
}
270256
}
271257

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-
292258
/**
293259
* Whether a site's application password is usable. Owned by [SiteProvisioningSource];
294260
* rendered by the application-password card.

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

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ 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
1110
import org.mockito.kotlin.eq
1211
import org.mockito.kotlin.never
1312
import org.mockito.kotlin.times
@@ -92,14 +91,6 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) {
9291
if (!ok) whenever(editorSettingsRepository.hasCachedCapabilities(any())).thenReturn(cached)
9392
}
9493

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-
10394
// region auth stage
10495

10596
@Test
@@ -183,33 +174,6 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) {
183174
verify(siteStore, never()).createApplicationPassword(any())
184175
}
185176

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-
213177
// endregion
214178

215179
// region recovery + capability stages

0 commit comments

Comments
 (0)