diff --git a/includes/class-donations.php b/includes/class-donations.php index ed758d3bdd..776929f517 100644 --- a/includes/class-donations.php +++ b/includes/class-donations.php @@ -661,6 +661,20 @@ public static function is_platform_other() { return 'other' === self::get_platform_slug(); } + /** + * Whether the current donation platform has a secure server-side mechanism + * to manage donor status (e.g., via the donation_new data event). + * + * Platforms with server-side tracking can enforce is_donor as read-only + * at the public API boundary. Platforms without it (NRH, other) need + * client-side write access for post-transaction landing page flows. + * + * @return bool + */ + public static function has_server_side_donor_tracking() { + return self::is_platform_wc(); + } + /** * Handle submission of the donation form. */ diff --git a/includes/reader-activation/class-reader-data.php b/includes/reader-activation/class-reader-data.php index f81e51e8a3..cc1c7ee0ab 100644 --- a/includes/reader-activation/class-reader-data.php +++ b/includes/reader-activation/class-reader-data.php @@ -67,21 +67,38 @@ public static function add_reader_data_to_hydration( $data, $user_id ) { * @return string[] Names of read-only keys. */ public static function get_read_only_keys() { + $keys = [ + 'active_memberships', + 'active_subscriptions', + 'is_former_donor', + 'newsletter_subscribed_lists', + ]; + + // is_donor is only read-only when the platform has a secure server-side + // mechanism to manage donor status. Currently only WooCommerce has this + // via the donation_new data event. Non-Woo platforms (NRH, other) rely + // on client-side writes from the donor landing page. + // + // Note: when is_donor is NOT read-only, any authenticated reader can + // set it via the REST API. This is an intentional trade-off — is_donor + // is used for segmentation and analytics, not access control. Consumers + // that need to distinguish server-verified from client-asserted donor + // status should check Donations::has_server_side_donor_tracking(). + if ( Donations::has_server_side_donor_tracking() ) { + $keys[] = 'is_donor'; + } + /** * Filters the list of read-only reader data keys. * + * This list is used for both client-side configuration (via wp_localize_script) + * and server-side REST API enforcement. Note that filter callbacks relying on + * page-context conditionals (is_page, get_the_ID, etc.) will only affect the + * client-side path. + * * @param string[] $keys Names of read-only keys. */ - return apply_filters( - 'newspack_reader_data_read_only_keys', - [ - 'active_memberships', - 'active_subscriptions', - 'is_former_donor', - 'is_donor', - 'newsletter_subscribed_lists', - ] - ); + return apply_filters( 'newspack_reader_data_read_only_keys', $keys ); } /** diff --git a/tests/unit-tests/reader-data-read-only-keys.php b/tests/unit-tests/reader-data-read-only-keys.php new file mode 100644 index 0000000000..2857183462 --- /dev/null +++ b/tests/unit-tests/reader-data-read-only-keys.php @@ -0,0 +1,151 @@ +custom_key_filter ) { + remove_filter( 'newspack_reader_data_read_only_keys', $this->custom_key_filter ); + $this->custom_key_filter = null; + } + parent::tear_down(); + } + + /** + * Test that has_server_side_donor_tracking() returns true for WooCommerce. + */ + public function test_has_server_side_donor_tracking_wc() { + Donations::set_platform_slug( 'wc' ); + self::assertTrue( + Donations::has_server_side_donor_tracking(), + 'WooCommerce platform should have server-side donor tracking.' + ); + } + + /** + * Test that has_server_side_donor_tracking() returns false for NRH. + */ + public function test_has_server_side_donor_tracking_nrh() { + Donations::set_platform_slug( 'nrh' ); + self::assertFalse( + Donations::has_server_side_donor_tracking(), + 'NRH platform should not have server-side donor tracking.' + ); + } + + /** + * Test that has_server_side_donor_tracking() returns false for other. + */ + public function test_has_server_side_donor_tracking_other() { + Donations::set_platform_slug( 'other' ); + self::assertFalse( + Donations::has_server_side_donor_tracking(), + 'Other platform should not have server-side donor tracking.' + ); + } + + /** + * Test that is_donor is read-only on WooCommerce platform. + */ + public function test_is_donor_read_only_on_wc() { + Donations::set_platform_slug( 'wc' ); + self::assertContains( + 'is_donor', + Reader_Data::get_read_only_keys(), + 'is_donor should be read-only on WooCommerce platform.' + ); + } + + /** + * Test that is_donor is writable on NRH platform. + */ + public function test_is_donor_writable_on_nrh() { + Donations::set_platform_slug( 'nrh' ); + self::assertNotContains( + 'is_donor', + Reader_Data::get_read_only_keys(), + 'is_donor should be writable on NRH platform.' + ); + } + + /** + * Test that is_donor is writable on other platform. + */ + public function test_is_donor_writable_on_other() { + Donations::set_platform_slug( 'other' ); + self::assertNotContains( + 'is_donor', + Reader_Data::get_read_only_keys(), + 'is_donor should be writable on other platform.' + ); + } + + /** + * Test that is_former_donor is always read-only regardless of platform. + * + * @dataProvider platform_provider + * @param string $platform Platform slug. + */ + public function test_is_former_donor_always_read_only( $platform ) { + Donations::set_platform_slug( $platform ); + self::assertContains( + 'is_former_donor', + Reader_Data::get_read_only_keys(), + "is_former_donor should be read-only on {$platform} platform." + ); + } + + /** + * Test that the newspack_reader_data_read_only_keys filter still works. + */ + public function test_filter_can_add_custom_key() { + $this->custom_key_filter = function ( $keys ) { + $keys[] = 'custom_key'; + return $keys; + }; + add_filter( 'newspack_reader_data_read_only_keys', $this->custom_key_filter ); + + self::assertContains( + 'custom_key', + Reader_Data::get_read_only_keys(), + 'Filter should be able to add custom read-only keys.' + ); + } + + /** + * Data provider for all platform slugs. + * + * @return array[] + */ + public function platform_provider() { + return [ + 'wc' => [ 'wc' ], + 'nrh' => [ 'nrh' ], + 'other' => [ 'other' ], + ]; + } +}