Skip to content

Commit 1ba5e7a

Browse files
authored
Make wpApiRestUrl, xmlRpcUrl, and app-password creds single-writer (#22947)
* Stop full-row site writes from clobbering wpApiRestUrl On Atomic / Jetpack-WPCom-REST sites a recovered wpApiRestUrl was wiped to NULL on every app foreground: any full-row insertOrUpdateSite UPDATE built from a partial in-memory SiteModel (FETCH_SITE/FETCH_SITES, RN, cookie-nonce) wrote every column, clobbering the healed value. Exclude WP_API_REST_URL from the generic UpdateAllExceptId mapper so updateWpApiRestUrl is the sole writer on an existing row, and route every legitimate writer through the targeted helpers. Fixes #22905. * Stop full-row site writes from clobbering app-password credentials The same full-row insertOrUpdateSite UPDATE that clobbered wpApiRestUrl also zeroed the application-password credential columns: a credential-less inbound SiteModel (e.g. a /me/sites sync) overwrote the encrypted username/password and their IVs with empty values. Exclude API_REST_USERNAME, API_REST_PASSWORD and their IV columns from the UpdateAllExceptId mapper (as a set — the IVs are required to decrypt), and route the credential writers through targeted single-column writers. Extends the #22905 fix to the credential columns. * Stop full-row site writes from clobbering xmlRpcUrl xmlRpcUrl is set either out of band (XML-RPC rediscovery) or by the WP.com REST sync (meta.links.xmlrpc). A full-row insertOrUpdateSite UPDATE built from a partial SiteModel that doesn't carry it — e.g. the WPAPI fetch, which builds a model with a null xmlRpcUrl — wrote that null over a good value. Preserve XMLRPC_URL on absence in the generic update path: keep the stored value when the inbound model has none, persist it when the model carries one (the WP.com sync, including a changed endpoint after a domain migration). Add a targeted updateXmlRpcUrl writer for the single-column rediscovery heal; the recovery flow that calls it lands separately. Extends the #22905 fix to XMLRPC_URL. * Persist rediscovered xmlRpcUrl via a targeted write attemptXmlRpcRediscovery dispatched newUpdateSiteAction to change one field, which (with XMLRPC_URL excluded from the full-row mapper) dropped the value and rewrote ~80 unchanged columns. Add SiteStore.persistXmlRpcUrl and route rediscovery through it — one targeted write, mirroring the wpApiRestUrl heal. Drops the now-unused dispatcher from the slice. * Persist app-password credentials on XML-RPC login of an existing site createOrUpdateSites (the XML-RPC app-password login store path) does a full-row write that excludes the credential columns, with no targeted writer to follow up — so re-logging into an already-stored self-hosted site silently dropped the credentials. Add SiteSqlUtils.insertOrUpdateSiteReturningId, which returns the local id of the row it wrote; insertOrUpdateSite becomes a thin rows-affected wrapper over it (behavior unchanged). createOrUpdateSites uses the returned id to persist credentials + wpApiRestUrl via the targeted writers on the exact row. * Remove dead site writes after the single-writer migration - The cookie-nonce and React Native 404 handlers reset wpApiRestUrl in memory to force rediscovery on retry, then persisted via insertOrUpdateSite/persistSiteSafely — now a no-op for the excluded column. Drop the dead DB write (keep the in-memory reset), which also orphaned ReactNativeStore.persistSiteSafely and its injected persist function; remove those. - updateSite / createOrUpdateSites copied credentials + wpApiRestUrl from the DB onto the inbound model before the write; the mapper exclusion already preserves those columns, so drop the moot copy (editor-prefs copy stays). - Rework ReactNativeStoreWPAPITest to mock SiteSqlUtils and assert on updateWpApiRestUrl (the discover path persists there now). * Collapse application-password handlers to their targeted writers After the single-writer migration the full-row write skips the credential and WP_API_REST_URL columns, so the in-memory mutations and the self-insertOrUpdateSite in updateApplicationPassword, removeApplicationPassword, and clearApplicationPasswordColumns persisted nothing — the targeted writers do all the work. Drop the dead code (keeping the new-site insert in updateApplicationPassword) and remove the now-unused insertOrUpdateSite stubs from the tests. * Skip credential decryption when the IV is missing decryptAPIRestCredentials bailed only when the ciphertext columns were empty; a row with ciphertext but a blank IV would reach decrypt(ciphertext, "") and throw on read. Also short-circuit when either IV is empty, treating the malformed row as having no credentials. * Test application-password credential writers against a real DB updateApplicationPasswordCredentials and its URL-keyed variant were verified only against a mocked SiteSqlUtils, so their ContentValues mapping never ran. A .first/.second swap, a wrong column, or a dropped IV would ship uncaught — and the new blank-IV decrypt guard would mask it as 'no credentials' rather than surfacing it. EncryptionUtils can't run under Robolectric (AndroidKeyStore), so add a stubbed-EncryptionUtils SiteSqlUtils and assert the column mapping via the raw storedSite() read, plus a decrypt round-trip and the ORIGIN_WPAPI URL-scoping (writes the matching row, leaves a same-url non-WPAPI row untouched). * Collapse the app-password persist paths behind one helper UPDATE_APPLICATION_PASSWORD, the WPAPI app-password fetch, and app-password XML-RPC login in createOrUpdateSites each duplicated the read-plain / null-guard / write-credentials-then-wpApiRestUrl logic, with subtle divergences (rowsAffected reassigned vs not; the URL write nested under the credentials check in one path, independent in another). Extract persistAppPasswordColumns(site, persistCredentials, persistWpApiRestUrl); each caller passes its targeted writer pair (local id or URL-keyed). wpApiRestUrl stays gated behind credentials on purpose: a credential-less /me/sites model can be a WP.com simple site whose getWpApiRestUrl() synthesizes a public-api proxy URL, and gating keeps that synthetic value out of the DB. removeApplicationPassword now sums both clear-writes instead of returning only the wpApiRestUrl count. * De-duplicate the credential-column writer; tighten a test updateApplicationPasswordCredentials and clearApplicationPasswordCredentials hand-wrote the same four-column ContentValues, so the column set lived in two places — a future column change has to touch both or clear leaves a stale column. Route both through one private writeApplicationPasswordCredentialColumns. ApplicationPasswordViewModelSliceTest: the xmlRpc-rediscovery-success assertion was pre-satisfied by @before seeding siteTest.xmlRpcUrl to the same value; reset it to null first so the assertion proves rediscovery assigned it.
1 parent a88923a commit 1ba5e7a

10 files changed

Lines changed: 764 additions & 142 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import kotlinx.coroutines.launch
99
import kotlinx.coroutines.withContext
1010
import org.wordpress.android.R
1111
import androidx.annotation.VisibleForTesting
12-
import org.wordpress.android.fluxc.Dispatcher
13-
import org.wordpress.android.fluxc.generated.SiteActionBuilder
1412
import org.wordpress.android.fluxc.model.SiteModel
1513
import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder
1614
import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient
@@ -41,7 +39,6 @@ class ApplicationPasswordViewModelSlice @Inject constructor(
4139
private val selfHostedEndpointFinder: SelfHostedEndpointFinder,
4240
private val siteXMLRPCClient: SiteXMLRPCClient,
4341
private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer,
44-
private val dispatcher: Dispatcher,
4542
private val credentialsChangedNotifier: CredentialsChangedNotifier,
4643
@Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher,
4744
) {
@@ -257,9 +254,10 @@ class ApplicationPasswordViewModelSlice @Inject constructor(
257254
}
258255

259256
site.xmlRpcUrl = xmlRpcEndpoint
260-
dispatcher.dispatch(
261-
SiteActionBuilder.newUpdateSiteAction(site)
262-
)
257+
// Persist only the rediscovered column — mirrors healApiRestUrlIfMissing. A full-row
258+
// updateSite would rewrite ~80 columns from this in-memory model for a one-field change
259+
// (risking clobbering other out-of-band values), so write just xmlRpcUrl.
260+
siteStore.persistXmlRpcUrl(site.id, xmlRpcEndpoint)
263261
buildCard(site)
264262
} catch (
265263
@Suppress("SwallowedException")

WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import org.mockito.kotlin.verify
1919
import org.mockito.kotlin.whenever
2020
import org.wordpress.android.BaseUnitTest
2121
import org.mockito.kotlin.mock
22-
import org.wordpress.android.fluxc.Dispatcher
2322
import org.wordpress.android.fluxc.model.SiteModel
2423
import org.wordpress.android.fluxc.model.SitesModel
2524
import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError
@@ -71,9 +70,6 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
7170
@Mock
7271
lateinit var siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer
7372

74-
@Mock
75-
lateinit var dispatcher: Dispatcher
76-
7773
@Mock
7874
lateinit var credentialsChangedNotifier: CredentialsChangedNotifier
7975

@@ -96,7 +92,6 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
9692
selfHostedEndpointFinder,
9793
siteXMLRPCClient,
9894
siteApiRestUrlRecoverer,
99-
dispatcher,
10095
credentialsChangedNotifier,
10196
testDispatcher()
10297
).apply {
@@ -380,8 +375,10 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
380375
}
381376

382377
@Test
383-
fun `given xmlRpc rediscovery and auth check succeed, then update site and dispatch`() =
378+
fun `given xmlRpc rediscovery and auth check succeed, then persist the discovered xmlRpcUrl`() =
384379
runTest {
380+
// @Before seeds siteTest.xmlRpcUrl; clear it so the final assertion proves rediscovery set it.
381+
siteTest.xmlRpcUrl = null
385382
val xmlRpcUrl = "https://www.test.com/xmlrpc.php"
386383
whenever(
387384
selfHostedEndpointFinder
@@ -392,16 +389,17 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
392389
eq(xmlRpcUrl), any(), any()
393390
)
394391
).thenReturn(SitesModel(listOf(SiteModel())))
392+
whenever(siteStore.persistXmlRpcUrl(any(), any())).thenReturn(SiteStore.OnSiteChanged(0))
395393

396394
applicationPasswordViewModelSlice
397395
.attemptXmlRpcRediscovery(siteTest)
398396

399-
verify(dispatcher).dispatch(any())
397+
verify(siteStore).persistXmlRpcUrl(siteTest.id, xmlRpcUrl)
400398
assert(siteTest.xmlRpcUrl == xmlRpcUrl)
401399
}
402400

403401
@Test
404-
fun `given xmlRpc rediscovery succeeds but auth check fails, then do not dispatch`() =
402+
fun `given xmlRpc rediscovery succeeds but auth check fails, then do not persist`() =
405403
runTest {
406404
siteTest.xmlRpcUrl = null
407405
val xmlRpcUrl = "https://www.test.com/xmlrpc.php"
@@ -421,12 +419,12 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
421419
applicationPasswordViewModelSlice
422420
.attemptXmlRpcRediscovery(siteTest)
423421

424-
verify(dispatcher, never()).dispatch(any())
422+
verify(siteStore, never()).persistXmlRpcUrl(any(), any())
425423
assert(siteTest.xmlRpcUrl.isNullOrEmpty())
426424
}
427425

428426
@Test
429-
fun `given xmlRpc rediscovery fails, then do not dispatch`() =
427+
fun `given xmlRpc rediscovery fails, then do not persist`() =
430428
runTest {
431429
siteTest.xmlRpcUrl = null
432430
whenever(
@@ -441,7 +439,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
441439

442440
verify(selfHostedEndpointFinder)
443441
.verifyOrDiscoverXMLRPCEndpoint(TEST_URL)
444-
verify(dispatcher, never()).dispatch(any())
442+
verify(siteStore, never()).persistXmlRpcUrl(any(), any())
445443
assert(siteTest.xmlRpcUrl.isNullOrEmpty())
446444
}
447445
}

libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/CookieNonceAuthenticator.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ class CookieNonceAuthenticator @Inject constructor(
5656
): T {
5757
val usingSavedRestUrl = site.wpApiRestUrl != null
5858
if (!usingSavedRestUrl) {
59-
site.wpApiRestUrl = discoverApiEndpoint(site.url)
60-
(siteSqlUtils::insertOrUpdateSite)(site)
59+
val discoveredUrl = discoverApiEndpoint(site.url)
60+
site.wpApiRestUrl = discoveredUrl
61+
// WP_API_REST_URL is excluded from full-row writes (see SiteSqlUtils), so persist the
62+
// freshly discovered value through its dedicated writer.
63+
siteSqlUtils.updateWpApiRestUrl(site.id, discoveredUrl)
6164
}
6265

6366
val response = makeAuthenticatedWPAPIRequest(
@@ -71,9 +74,9 @@ class CookieNonceAuthenticator @Inject constructor(
7174

7275
return if (response is WPAPIResponse.Error<*> &&
7376
response.error.volleyError?.networkResponse?.statusCode == STATUS_CODE_NOT_FOUND) {
74-
// call failed with 'not found' so clear the (failing) rest url
77+
// Reset the in-memory rest url so the retry rediscovers it. The stored value is left intact
78+
// (a 404 may be transient); WP_API_REST_URL is healed only via updateWpApiRestUrl.
7579
site.wpApiRestUrl = null
76-
(siteSqlUtils::insertOrUpdateSite)(site)
7780

7881
if (usingSavedRestUrl) {
7982
// If we did the previous call with a saved rest url, try again by making

libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/SiteSqlUtils.kt

Lines changed: 152 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,17 @@ class SiteSqlUtils
105105
.decryptAPIRestCredentials()
106106

107107
/**
108-
* Inserts the given SiteModel into the DB, or updates an existing entry where sites match.
108+
* Inserts or updates [site] and returns the number of rows affected (1 if a row was written, 0
109+
* otherwise). Use [insertOrUpdateSiteReturningId] when you need the local id of the written row.
110+
*/
111+
@Throws(DuplicateSiteException::class)
112+
fun insertOrUpdateSite(site: SiteModel?): Int =
113+
if (insertOrUpdateSiteReturningId(site) != 0) 1 else 0
114+
115+
/**
116+
* Inserts the given SiteModel into the DB, or updates an existing entry where sites match, returning the
117+
* local id of the written row (0 if nothing was written). Returning the id lets callers target that exact
118+
* row with the single-column writers without a second, fragile lookup.
109119
*
110120
* Possible cases:
111121
* 1. Exists in the DB already and matches by local id (simple update) -> UPDATE
@@ -117,7 +127,7 @@ class SiteSqlUtils
117127
*/
118128
@Suppress("LongMethod", "ReturnCount", "ComplexMethod")
119129
@Throws(DuplicateSiteException::class)
120-
fun insertOrUpdateSite(site: SiteModel?): Int {
130+
fun insertOrUpdateSiteReturningId(site: SiteModel?): Int {
121131
if (site == null) {
122132
return 0
123133
}
@@ -214,15 +224,39 @@ class SiteSqlUtils
214224
return if (siteResult.isEmpty()) {
215225
// No site with this local ID, REMOTE_ID + URL, or XMLRPC URL, then insert it
216226
AppLog.d(DB, "Inserting site: " + finalSiteModel.url)
227+
// WellSql back-fills the auto-assigned id onto the model on insert (Identifiable.setId).
217228
WellSql.insert(finalSiteModel).asSingleTransaction(true).execute()
218-
1
229+
finalSiteModel.id
219230
} else {
220231
// Update old site
221232
AppLog.d(DB, "Updating site: " + finalSiteModel.url)
222233
val oldId = siteResult[0].id
223234
try {
235+
// WP_API_REST_URL and the application-password credential columns are discovered/healed out
236+
// of band — never carried by the general site-sync responses — so they're excluded from the
237+
// generic mapper and written only by their dedicated writers (updateWpApiRestUrl /
238+
// updateApplicationPasswordCredentials); a stale full-row write must not clobber them.
239+
//
240+
// XMLRPC_URL is different: the WP.com REST sync reliably carries it (meta.links.xmlrpc), so
241+
// the generic write persists it — that's how a changed endpoint (e.g. a domain migration)
242+
// lands. But partial writers that don't carry it (the WPAPI fetch builds a model with a null
243+
// xmlRpcUrl) must not clear a stored/rediscovered value, so preserve it on absence.
244+
if (finalSiteModel.xmlRpcUrl.isNullOrEmpty()) {
245+
finalSiteModel.xmlRpcUrl = siteResult[0].xmlRpcUrl
246+
}
224247
WellSql.update(SiteModel::class.java).whereId(oldId)
225-
.put(finalSiteModel, UpdateAllExceptId(SiteModel::class.java)).execute()
248+
.put(
249+
finalSiteModel,
250+
UpdateAllExceptId(
251+
SiteModel::class.java,
252+
SiteModelTable.WP_API_REST_URL,
253+
SiteModelTable.API_REST_USERNAME,
254+
SiteModelTable.API_REST_PASSWORD,
255+
SiteModelTable.API_REST_USERNAME_IV,
256+
SiteModelTable.API_REST_PASSWORD_IV
257+
)
258+
).execute()
259+
oldId
226260
} catch (e: SQLiteConstraintException) {
227261
AppLog.e(
228262
DB,
@@ -260,6 +294,11 @@ class SiteSqlUtils
260294
}).execute()
261295
}
262296

297+
/**
298+
* Targeted writer for [SiteModel.wpApiRestUrl]. This is the sole writer of WP_API_REST_URL on an
299+
* existing row: the generic full-row update path ([insertOrUpdateSite]) excludes the column so that
300+
* stale in-memory sites can't clobber a value that was healed/discovered out of band.
301+
*/
263302
fun updateWpApiRestUrl(localId: Int, wpApiRestUrl: String): Int {
264303
return WellSql.update(SiteModel::class.java)
265304
.whereId(localId)
@@ -270,6 +309,109 @@ class SiteSqlUtils
270309
}).execute()
271310
}
272311

312+
/**
313+
* Clears [SiteModel.wpApiRestUrl] for the given local id. Use this instead of a full-row update when an
314+
* explicit action (e.g. removing an application password) needs to drop the stored REST URL, since the
315+
* generic update path no longer touches the column.
316+
*/
317+
fun clearWpApiRestUrl(localId: Int): Int = updateWpApiRestUrl(localId, "")
318+
319+
/**
320+
* Targeted writer for [SiteModel.xmlRpcUrl], used by the XML-RPC rediscovery heal to persist just that
321+
* one column without a full-row write of an in-memory model. Unlike [updateWpApiRestUrl], XMLRPC_URL is
322+
* NOT excluded from the generic mapper — the WP.com sync reliably carries it — but the generic update
323+
* path preserves it on absence, so a partial write can't clobber a rediscovered value. (The recovery flow
324+
* that calls this lands separately.)
325+
*/
326+
fun updateXmlRpcUrl(localId: Int, xmlRpcUrl: String): Int {
327+
return WellSql.update(SiteModel::class.java)
328+
.whereId(localId)
329+
.put(xmlRpcUrl, { value ->
330+
val cv = ContentValues()
331+
cv.put(SiteModelTable.XMLRPC_URL, value)
332+
cv
333+
}).execute()
334+
}
335+
336+
/**
337+
* Targeted writer for the application-password credential columns: the encrypted username and password
338+
* and their IVs. The four are written as a set — the IVs are required to decrypt the ciphertext, so
339+
* persisting the values without their IVs would break reads. This is the sole writer of these columns on
340+
* an existing row: the generic full-row update path excludes them so a credential-less inbound SiteModel
341+
* (e.g. a /me/sites sync model) can't zero them out.
342+
*/
343+
fun updateApplicationPasswordCredentials(localId: Int, usernamePlain: String, passwordPlain: String): Int {
344+
val username = encryptionUtils.encrypt(usernamePlain)
345+
val password = encryptionUtils.encrypt(passwordPlain)
346+
return writeApplicationPasswordCredentialColumns(
347+
localId, username.first, username.second, password.first, password.second
348+
)
349+
}
350+
351+
/**
352+
* Clears the application-password credential columns (encrypted username/password and their IVs) for the
353+
* given local id. Companion to [updateApplicationPasswordCredentials] for the sign-out / revoke paths,
354+
* since the generic update path no longer touches these columns.
355+
*/
356+
fun clearApplicationPasswordCredentials(localId: Int): Int =
357+
writeApplicationPasswordCredentialColumns(localId, "", "", "", "")
358+
359+
/**
360+
* The single place that maps the four application-password credential columns to their values, so the
361+
* column set lives in one spot. Callers pass already-encrypted values (or empty strings to clear); each
362+
* IV always travels with its ciphertext, so a row can never be left holding one without the other.
363+
*/
364+
private fun writeApplicationPasswordCredentialColumns(
365+
localId: Int,
366+
usernameCipher: String,
367+
usernameIv: String,
368+
passwordCipher: String,
369+
passwordIv: String
370+
): Int {
371+
return WellSql.update(SiteModel::class.java)
372+
.whereId(localId)
373+
.put(localId, { _ ->
374+
val cv = ContentValues()
375+
cv.put(SiteModelTable.API_REST_USERNAME, usernameCipher)
376+
cv.put(SiteModelTable.API_REST_USERNAME_IV, usernameIv)
377+
cv.put(SiteModelTable.API_REST_PASSWORD, passwordCipher)
378+
cv.put(SiteModelTable.API_REST_PASSWORD_IV, passwordIv)
379+
cv
380+
}).execute()
381+
}
382+
383+
/**
384+
* URL-keyed variant of [updateWpApiRestUrl] for application-password (ORIGIN_WPAPI) sites, which are
385+
* fetched as fresh models with no local id (see SiteWPAPIRestClient.fetchWPAPISite). Scoped to
386+
* ORIGIN_WPAPI so it can't touch a WP.com/Jetpack row that happens to share the same URL.
387+
*/
388+
fun updateWpApiRestUrlForWPAPISite(siteUrl: String, wpApiRestUrl: String): Int {
389+
val localId = wpApiSiteLocalIdByUrl(siteUrl) ?: return 0
390+
return updateWpApiRestUrl(localId, wpApiRestUrl)
391+
}
392+
393+
/**
394+
* URL-keyed variant of [updateApplicationPasswordCredentials] for ORIGIN_WPAPI sites fetched without a
395+
* local id. See [updateWpApiRestUrlForWPAPISite].
396+
*/
397+
fun updateApplicationPasswordCredentialsForWPAPISite(
398+
siteUrl: String,
399+
usernamePlain: String,
400+
passwordPlain: String
401+
): Int {
402+
val localId = wpApiSiteLocalIdByUrl(siteUrl) ?: return 0
403+
return updateApplicationPasswordCredentials(localId, usernamePlain, passwordPlain)
404+
}
405+
406+
private fun wpApiSiteLocalIdByUrl(siteUrl: String): Int? =
407+
WellSql.select(SiteModel::class.java)
408+
.where().beginGroup()
409+
.equals(SiteModelTable.URL, siteUrl)
410+
.equals(SiteModelTable.ORIGIN, SiteModel.ORIGIN_WPAPI)
411+
.endGroup().endWhere()
412+
.asModel
413+
.firstOrNull()?.id
414+
273415
val wPComSites: SelectQuery<SiteModel>
274416
get() = WellSql.select(SiteModel::class.java)
275417
.where().beginGroup()
@@ -573,14 +715,17 @@ class SiteSqlUtils
573715
return this.map { it.decryptAPIRestCredentials() }
574716
}
575717

576-
@Suppress("ReturnCount")
718+
@Suppress("ReturnCount", "ComplexCondition")
577719
private fun SiteModel.decryptAPIRestCredentials(): SiteModel {
578720
// If already decrypted, do nothing
579721
if (!apiRestUsernamePlain.isNullOrEmpty() && !apiRestPasswordPlain.isNullOrEmpty()) {
580722
return this
581723
}
582-
// If the encrypted credentials are empty, there's nothing to decrypt
583-
if (apiRestUsernameEncrypted.isNullOrEmpty() || apiRestPasswordEncrypted.isNullOrEmpty()) {
724+
// If the encrypted credentials — or the IVs required to decrypt them — are empty, there's nothing to
725+
// safely decrypt. The ciphertext/IV pairs are always written together, so a row missing any one is
726+
// treated as having no decryptable credentials rather than risking a decrypt failure on a blank IV.
727+
if (apiRestUsernameEncrypted.isNullOrEmpty() || apiRestPasswordEncrypted.isNullOrEmpty() ||
728+
apiRestUsernameIV.isNullOrEmpty() || apiRestPasswordIV.isNullOrEmpty()) {
584729
return this
585730
}
586731
apiRestUsernamePlain = encryptionUtils.decrypt(apiRestUsernameEncrypted, apiRestUsernameIV)

libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/UpdateAllExceptId.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,29 @@
88

99
class UpdateAllExceptId<T> implements InsertMapper<T> {
1010
private final SQLiteMapper<T> mMapper;
11+
private final String[] mAdditionalColumnsToSkip;
1112

1213
UpdateAllExceptId(Class<T> clazz) {
14+
this(clazz, new String[0]);
15+
}
16+
17+
/**
18+
* @param additionalColumnsToSkip columns (beyond the primary key) that should be left untouched by the
19+
* resulting UPDATE. Use this when a column has a dedicated writer and must
20+
* not be overwritten by full-row updates built from partial in-memory models.
21+
*/
22+
UpdateAllExceptId(Class<T> clazz, String... additionalColumnsToSkip) {
1323
mMapper = WellSql.mapperFor(clazz);
24+
mAdditionalColumnsToSkip = additionalColumnsToSkip;
1425
}
1526

1627
@Override
1728
public ContentValues toCv(T item) {
1829
ContentValues cv = mMapper.toCv(item);
1930
cv.remove("_id");
31+
for (String column : mAdditionalColumnsToSkip) {
32+
cv.remove(column);
33+
}
2034
return cv;
2135
}
2236
}

0 commit comments

Comments
 (0)