From a710d22b590daba1a9abddce6c44fc848b035ae3 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 4 May 2026 10:20:19 +1200 Subject: [PATCH 1/4] Reproduce non-bool `extra_capabilities` deserialization failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two fixture admin users to the integration test setup whose `wp_capabilities` usermeta is poisoned via `wp eval` to exercise the two non-bool shapes that break clients today: - `legacy_admin` carries `{"administrator": "1"}`, mimicking pre-2012 WP sites whose role assignments were written as the string `"1"`. This is the exact failure mode reported in #1313 — the iOS app cannot parse `users/me?context=edit` for these users. - `wpbakery_admin` carries `{"administrator": true, "vc_access_rules_post_types": "custom"}`, mimicking plugins that call `WP_User::add_cap($cap, $grant)` with non-bool grants. PR #1263 already fixed this on `capabilities` with a unit test; this gives it integration coverage too. Tests fetch each user by ID via the default admin client and rely on `assert_response()` to surface deserialization failures. Both fail today on `extra_capabilities`, which is still typed `Option>`. The fix follows in the next commit. --- integration_test_credentials/src/lib.rs | 2 ++ scripts/setup-test-site.sh | 15 ++++++++ .../tests/test_users_immut.rs | 35 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/integration_test_credentials/src/lib.rs b/integration_test_credentials/src/lib.rs index d0feb470a..8daf9bfca 100644 --- a/integration_test_credentials/src/lib.rs +++ b/integration_test_credentials/src/lib.rs @@ -13,6 +13,8 @@ pub struct TestCredentials { pub subscriber_password_uuid: &'static str, pub author_username: &'static str, pub author_password: &'static str, + pub legacy_admin_user_id: i64, + pub wpbakery_admin_user_id: i64, pub password_protected_post_id: i64, pub password_protected_post_password: &'static str, pub password_protected_post_title: &'static str, diff --git a/scripts/setup-test-site.sh b/scripts/setup-test-site.sh index 0f1091702..33b3ada79 100755 --- a/scripts/setup-test-site.sh +++ b/scripts/setup-test-site.sh @@ -183,6 +183,19 @@ create_test_credentials () { AUTHOR_USERNAME="test_author" AUTHOR_PASSWORD="$(wp user application-password create test_author test --porcelain)" + # Fixture admin users with `wp_capabilities` shapes that exercise non-bool capability values. + # These reproduce the deserialization failures fixed by PR #1263 (`capabilities`) and + # issue #1313 (`extra_capabilities`). + LEGACY_ADMIN_USERNAME="legacy_admin" + LEGACY_ADMIN_USER_ID="$(wp user create "$LEGACY_ADMIN_USERNAME" legacy_admin@example.com --role=administrator --porcelain)" + # Pre-2012 sites stored role assignments as the string "1" rather than boolean true. + wp eval "update_user_meta( $LEGACY_ADMIN_USER_ID, 'wp_capabilities', array( 'administrator' => '1' ) );" + + WPBAKERY_ADMIN_USERNAME="wpbakery_admin" + WPBAKERY_ADMIN_USER_ID="$(wp user create "$WPBAKERY_ADMIN_USERNAME" wpbakery_admin@example.com --role=administrator --porcelain)" + # WPBakery and similar plugins call `WP_User::add_cap($cap, $grant)` with non-bool grants. + wp eval "update_user_meta( $WPBAKERY_ADMIN_USER_ID, 'wp_capabilities', array( 'administrator' => true, 'vc_access_rules_post_types' => 'custom' ) );" + PASSWORD_PROTECTED_POST_ID="$(wp post create --post_type=post --post_password=INTEGRATION_TEST --post_title=Password_Protected --porcelain)" TRASHED_POST_ID="$(wp post create --post_type=post --post_title=Trashed_Post --porcelain)" @@ -321,6 +334,8 @@ create_test_credentials () { subscriber_password_uuid="$SUBSCRIBER_PASSWORD_UUID" \ author_username="$AUTHOR_USERNAME" \ author_password="$AUTHOR_PASSWORD" \ + legacy_admin_user_id="$LEGACY_ADMIN_USER_ID" \ + wpbakery_admin_user_id="$WPBAKERY_ADMIN_USER_ID" \ password_protected_post_id="$PASSWORD_PROTECTED_POST_ID" \ password_protected_post_password="INTEGRATION_TEST" \ password_protected_post_title="Password_Protected" \ diff --git a/wp_api_integration_tests/tests/test_users_immut.rs b/wp_api_integration_tests/tests/test_users_immut.rs index 263d2d219..f26c9d6a9 100644 --- a/wp_api_integration_tests/tests/test_users_immut.rs +++ b/wp_api_integration_tests/tests/test_users_immut.rs @@ -136,6 +136,41 @@ async fn retrieve_user_with_view_context(#[values(FIRST_USER_ID, SECOND_USER_ID) assert_eq!(user_id, user.id); } +// Regression test for issue #1313: legacy WordPress sites can store the role assignment +// in `wp_capabilities` as the string `"1"` instead of boolean `true`. The fixture user +// `legacy_admin` has its `wp_capabilities` poisoned to `{"administrator": "1"}` so the +// REST response carries the legacy string shape on `extra_capabilities` (raw user meta) +// and on `capabilities` (allcaps merged with the user-level overlay). +#[tokio::test] +#[parallel] +async fn retrieve_legacy_admin_with_edit_context_parses() { + let user_id = UserId(TestCredentials::instance().legacy_admin_user_id); + let user = api_client() + .users() + .retrieve_with_edit_context(&user_id) + .await + .assert_response() + .data; + assert_eq!(user_id, user.id); +} + +// Regression test for PR #1263: plugins like WPBakery call `WP_User::add_cap($cap, $grant)` +// with non-bool grants, leaving entries such as `"vc_access_rules_post_types": "custom"` in +// `wp_capabilities`. The fixture user `wpbakery_admin` carries both a boolean role entry +// and a plugin-style string entry, exercising the deserializer on both response fields. +#[tokio::test] +#[parallel] +async fn retrieve_wpbakery_admin_with_edit_context_parses() { + let user_id = UserId(TestCredentials::instance().wpbakery_admin_user_id); + let user = api_client() + .users() + .retrieve_with_edit_context(&user_id) + .await + .assert_response() + .data; + assert_eq!(user_id, user.id); +} + #[tokio::test] #[parallel] async fn retrieve_me_with_edit_context() { From 83584349dfcadbfdb8ef1a2d9c79e44b54c27584 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 4 May 2026 10:23:39 +1200 Subject: [PATCH 2/4] Fix `extra_capabilities` deserialization for non-bool values Switch `SparseUser::extra_capabilities` from `Option>` to `Option`, mirroring the wrapper PR #1263 introduced for the sibling `capabilities` field. The underlying `HashMap` tolerates the non-bool shapes that legacy WordPress and plugins like WPBakery write into `wp_capabilities` usermeta, and the existing `has_cap()` helper preserves the boolean-check semantics callers expect. Tighten the integration tests added in the previous commit to assert both the raw map contents and `has_cap()` behavior on the now-rich wrapper type. This is a Swift/Kotlin binding change consistent with PR #1263: `UserWithEditContext.extraCapabilities` becomes `UserCapabilitiesMap` instead of `[String: Bool]`. Fixes #1313. --- wp_api/src/users.rs | 2 +- .../tests/test_users_immut.rs | 22 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/wp_api/src/users.rs b/wp_api/src/users.rs index 320f9fb5e..2edcd7e1e 100644 --- a/wp_api/src/users.rs +++ b/wp_api/src/users.rs @@ -509,7 +509,7 @@ pub struct SparseUser { #[WpContext(edit)] pub capabilities: Option, #[WpContext(edit)] - pub extra_capabilities: Option>, + pub extra_capabilities: Option, #[WpContext(edit, embed, view)] // According to our tests, `avatar_urls` is not available for all site types. It's marked with // `#[WpContextual]` which will make it an `Option` in the generated contextual types. diff --git a/wp_api_integration_tests/tests/test_users_immut.rs b/wp_api_integration_tests/tests/test_users_immut.rs index f26c9d6a9..3f921407f 100644 --- a/wp_api_integration_tests/tests/test_users_immut.rs +++ b/wp_api_integration_tests/tests/test_users_immut.rs @@ -1,8 +1,8 @@ use reusable_test_cases::list_users_cases; use wp_api::users::{ SparseUserFieldWithEditContext, SparseUserFieldWithEmbedContext, - SparseUserFieldWithViewContext, UserId, UserListParams, WpApiParamUsersHasPublishedPosts, - WpApiParamUsersOrderBy, WpApiParamUsersWho, + SparseUserFieldWithViewContext, UserCapability, UserId, UserListParams, + WpApiParamUsersHasPublishedPosts, WpApiParamUsersOrderBy, WpApiParamUsersWho, }; use wp_api_integration_tests::prelude::*; @@ -152,6 +152,14 @@ async fn retrieve_legacy_admin_with_edit_context_parses() { .assert_response() .data; assert_eq!(user_id, user.id); + let administrator = UserCapability::Custom("administrator".to_string()); + assert!(user.extra_capabilities.map.contains_key(&administrator)); + assert!(user.capabilities.map.contains_key(&administrator)); + // The string `"1"` is not boolean `true`, so `has_cap` reports false even though + // the legacy site treats this user as an administrator. Other admin caps come from + // the role's defaults (booleans), so they still answer true. + assert!(!user.extra_capabilities.has_cap(administrator)); + assert!(user.capabilities.has_cap(UserCapability::EditPosts)); } // Regression test for PR #1263: plugins like WPBakery call `WP_User::add_cap($cap, $grant)` @@ -169,6 +177,16 @@ async fn retrieve_wpbakery_admin_with_edit_context_parses() { .assert_response() .data; assert_eq!(user_id, user.id); + let plugin_key = UserCapability::Custom("vc_access_rules_post_types".to_string()); + assert!(user.extra_capabilities.map.contains_key(&plugin_key)); + assert!(user.capabilities.map.contains_key(&plugin_key)); + // String `"custom"` is not boolean `true`, so `has_cap` reports false on the plugin + // entry, while the boolean administrator role still answers true. + assert!(!user.capabilities.has_cap(plugin_key)); + assert!( + user.extra_capabilities + .has_cap(UserCapability::Custom("administrator".to_string())) + ); } #[tokio::test] From 772b12ed50182d8233fdee8c8cdba7a9c18b2023 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 4 May 2026 10:36:51 +1200 Subject: [PATCH 3/4] Add CHANGELOG entries for `extra_capabilities` fix Document the breaking type change and the underlying bug fix from the preceding commit. Mirrors the conventions used for the analogous breaking changes elsewhere in the Unreleased section. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7dcdd8d..dd4bde500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** `PostCreateParams::meta`, `PostUpdateParams::meta`, and `SparseAnyPost::meta` are now `Option>` instead of `Option` - **BREAKING:** [Replace `WpService.selfHosted` and `WpService.wordpressCom` with a single `WpService.new(siteInfo:)` constructor](https://github.com/Automattic/wordpress-rs/pull/1239). `SiteInfo.SelfHosted` now carries `ParsedUrl` values for `site_url` and `api_root` instead of `String`, and the `wordpress_com_site_api_root` helper has been removed — construct `SiteInfo.WordPressCom` directly. - **BREAKING:** [Swift `WordPressAPI` initializers now accept a `SiteInfo`](https://github.com/Automattic/wordpress-rs/pull/1239) instead of `apiRootUrl`/`apiUrlResolver`, and the `siteUrl` parameter is now a `ParsedUrl` rather than a `String`. +- **BREAKING:** `SparseUser::extra_capabilities` is now `Option` instead of `Option>`, mirroring the wrapper used by `SparseUser::capabilities`. In Swift and Kotlin bindings, `UserWithEditContext.extraCapabilities` is now `UserCapabilitiesMap` rather than `[String: Bool]` / `Map` - Reformat `CHANGELOG.md` to follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and enforce changelog updates on PRs via a Buildkite check that also surfaces the failure as a GitHub PR comment (using the shared `comment_on_pr` helper from `a8c-ci-toolkit`) - Release documentation @@ -39,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Support both Integer and String for `WPApiDetails.gmt_offset`](https://github.com/Automattic/wordpress-rs/pull/209) - Pin `wp-cli/restful` to v0.4.1 to fix Docker build (v0.4.2+ requires unreleased `wp-cli ^2.13`) - **Internal:** `WpApiCache` lookups for a self-hosted site by URL now tolerate trailing-slash and other URL-normalization differences. +- [Deserialize `extra_capabilities` on legacy WordPress sites and on sites where plugins write non-bool capability values to `wp_capabilities` usermeta](https://github.com/Automattic/wordpress-rs/issues/1313) ## [0.1] From dc1932e2980bacb6125f56c1cb2316c9474e8f24 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 4 May 2026 12:52:55 +1200 Subject: [PATCH 4/4] Update Kotlin user-count assertions for the new fixture admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture admin users `legacy_admin` and `wpbakery_admin` introduced earlier in this branch raise the total user count on the test site from 4 to 6, which broke `UsersEndpointTest.testUserListRequest` and `testFilterUserListRequest`. Bump `NUMBER_OF_USERS` to 6 and split out `NUMBER_OF_USERS_WITH_PUBLISHED_POSTS` so the `testUserListRequestWithHasPublishedPostsParam` assertion no longer relies on the incidental `NUMBER_OF_USERS - 1` arithmetic — adding more non-publishing fixture users in the future no longer silently desyncs that test. --- .../src/integrationTest/kotlin/IntegrationTestHelpers.kt | 3 ++- .../api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt index 7407f1c1c..12f9f3ef3 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt @@ -18,7 +18,8 @@ const val SECOND_USER_ID: UserId = 2 const val FIRST_USER_EMAIL = "test@example.com" const val SECOND_USER_EMAIL = "themeshaperwp+demos@gmail.com" const val SECOND_USER_SLUG = "themedemos" -const val NUMBER_OF_USERS = 4 +const val NUMBER_OF_USERS = 6 +const val NUMBER_OF_USERS_WITH_PUBLISHED_POSTS = 3 const val NUMBER_OF_PLUGINS = 6 const val HELLO_DOLLY_PLUGIN_SLUG = "hello-dolly/hello" const val WP_ORG_PLUGIN_SLUG_CLASSIC_WIDGETS = "classic-widgets" diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt index 1daf835c9..b7ddc9e83 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt @@ -28,8 +28,7 @@ class UsersEndpointTest { val userList = client.request { requestBuilder -> requestBuilder.users().listWithEditContext(params) } .assertSuccessAndRetrieveData().data - // One of the test users don't have any posts or pages - assertEquals(NUMBER_OF_USERS - 1, userList.count()) + assertEquals(NUMBER_OF_USERS_WITH_PUBLISHED_POSTS, userList.count()) } @Test