From 18d80a20ea6c47ef7524510a95bea3d78e3be945 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 12:59:59 +0800 Subject: [PATCH 01/10] Premium Analytics: support view_stats-level dashboard access --- .../changelog/wooa7s-1779-view-stats-access | 4 + .../src/REST/class-notices-controller.php | 5 +- .../premium-analytics/src/capabilities.php | 92 +++++++++++++++ .../premium-analytics/src/class-analytics.php | 6 +- .../src/dashboard-layout.php | 5 +- .../src/dashboard-sections.php | 20 +++- .../src/widget-availability.php | 56 +++++++++ .../premium-analytics/src/widget-modules.php | 5 +- .../tests/php/Analytics_Test.php | 14 ++- .../tests/php/Capabilities_Test.php | 109 ++++++++++++++++++ .../tests/php/Dashboard_Layout_Test.php | 18 +++ .../tests/php/Dashboard_Section_Test.php | 87 ++++++++++++++ .../tests/php/Widget_Availability_Test.php | 43 +++++++ .../tests/php/Widget_Modules_Test.php | 18 +++ .../traits/trait-analytics-capabilities.php | 56 +++++++++ 15 files changed, 527 insertions(+), 11 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-1779-view-stats-access create mode 100644 projects/packages/premium-analytics/src/capabilities.php create mode 100644 projects/packages/premium-analytics/tests/php/Capabilities_Test.php create mode 100644 projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1779-view-stats-access b/projects/packages/premium-analytics/changelog/wooa7s-1779-view-stats-access new file mode 100644 index 000000000000..8c5c161bd1eb --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1779-view-stats-access @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Allow users with the view_stats capability to open the Analytics dashboard. diff --git a/projects/packages/premium-analytics/src/REST/class-notices-controller.php b/projects/packages/premium-analytics/src/REST/class-notices-controller.php index 77e31f40dddb..f573a4a156eb 100644 --- a/projects/packages/premium-analytics/src/REST/class-notices-controller.php +++ b/projects/packages/premium-analytics/src/REST/class-notices-controller.php @@ -10,6 +10,9 @@ use Automattic\Jetpack\PremiumAnalytics\Notices; use WP_REST_Request; use WP_REST_Server; +use function Automattic\Jetpack\PremiumAnalytics\current_user_can_view_analytics; + +require_once __DIR__ . '/../capabilities.php'; /** * Exposes `jetpack-premium-analytics/v1/notices` (GET + POST). @@ -99,7 +102,7 @@ public function register_routes(): void { * @return bool */ public function check_permission(): bool { - return current_user_can( 'manage_options' ) || current_user_can( 'view_stats' ); + return current_user_can_view_analytics(); } /** diff --git a/projects/packages/premium-analytics/src/capabilities.php b/projects/packages/premium-analytics/src/capabilities.php new file mode 100644 index 000000000000..ba2310d2c109 --- /dev/null +++ b/projects/packages/premium-analytics/src/capabilities.php @@ -0,0 +1,92 @@ + \WP_REST_Server::READABLE, 'callback' => __NAMESPACE__ . '\\get_dashboard_default_layout_response', - 'permission_callback' => static function () { - return current_user_can( 'manage_options' ); - }, + 'permission_callback' => __NAMESPACE__ . '\\current_user_can_view_analytics', 'args' => array( 'name' => array( 'description' => __( 'Dashboard identifier as produced by the build pipeline.', 'jetpack-premium-analytics-pkg' ), diff --git a/projects/packages/premium-analytics/src/dashboard-sections.php b/projects/packages/premium-analytics/src/dashboard-sections.php index 376393f34634..b63fb85e31e7 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -9,6 +9,7 @@ require_once __DIR__ . '/dashboard-layout.php'; require_once __DIR__ . '/dashboard-grammar.php'; +require_once __DIR__ . '/capabilities.php'; require_once __DIR__ . '/class-dashboard-section.php'; require_once __DIR__ . '/class-dashboard-section-registry.php'; @@ -66,6 +67,21 @@ function is_woocommerce_dashboard_section_available() { return (bool) apply_filters( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, $is_available ); } +/** + * Whether the current user should be shown the WooCommerce dashboard section. + * + * Kept apart from is_woocommerce_dashboard_section_available(), which answers + * "is WooCommerce here" and is also the signal the widget registry reads. This + * one adds "and may this reader see store data". + * + * @since $$next-version$$ + * + * @return bool + */ +function is_woocommerce_dashboard_section_available_to_current_user() { + return is_woocommerce_dashboard_section_available() && current_user_can_view_commerce_analytics(); +} + /** * Returns the default widget layout for the WooCommerce dashboard section. * @@ -108,7 +124,7 @@ function register_default_dashboard_sections() { 'woocommerce/store' => array( 'label' => __( 'Store', 'jetpack-premium-analytics-pkg' ), 'order' => 40, - 'is_available' => __NAMESPACE__ . '\\is_woocommerce_dashboard_section_available', + 'is_available' => __NAMESPACE__ . '\\is_woocommerce_dashboard_section_available_to_current_user', 'default_layout' => __NAMESPACE__ . '\\get_woocommerce_dashboard_section_default_layout', ), ); @@ -139,7 +155,7 @@ function bootstrap_dashboard_sections() { * @return bool */ function check_dashboard_sections_permission() { - return current_user_can( 'manage_options' ); + return current_user_can_view_analytics(); } /** diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index dc73887e3321..b4de681ece1f 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -17,6 +17,7 @@ require_once __DIR__ . '/widget-types.php'; require_once __DIR__ . '/widget-type-support.php'; +require_once __DIR__ . '/capabilities.php'; /** * Widget categories that are only meaningful with WooCommerce active. @@ -31,6 +32,14 @@ */ const WOOCOMMERCE_BOOKINGS_WIDGET_CATEGORIES = array( 'bookings' ); +/** + * Widget categories whose data the proxy only serves to administrators. + * + * Every commerce category, whichever plugin backs it: they all read through the + * proxy's `analytics` prefix. + */ +const COMMERCE_WIDGET_CATEGORIES = array( 'store', 'orders', 'coupons', 'bookings' ); + /** * Removes developer-only candidates in production. * @@ -154,3 +163,50 @@ function filter_registrable_widget_types_by_plugin( $widget_candidates ) { } add_filter( REGISTRABLE_WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_registrable_widget_types_by_plugin' ); + +/** + * Removes commerce candidates the reader could not load data for anyway. + * + * Split from the hook callback so both branches are testable without a user. + * + * @since $$next-version$$ + * + * @param array $widget_candidates Manifest candidates, each with a `category`. + * @param bool $can_view_commerce Whether the reader may see store data. + * @return array The candidates, minus commerce categories for readers who can't. + */ +function remove_capability_gated_widget_types( $widget_candidates, $can_view_commerce ) { + if ( $can_view_commerce ) { + return $widget_candidates; + } + + return array_values( + array_filter( + $widget_candidates, + static function ( $widget ) { + return ! in_array( $widget['category'] ?? '', COMMERCE_WIDGET_CATEGORIES, true ); + } + ) + ); +} + +/** + * Registry-time callback: hides commerce widgets from readers without store access. + * + * A `view_stats` reader reaching a store widget would only collect 403s from the + * proxy's `analytics` prefix, so the types are never registered for them. The + * registry is request-scoped, so filtering on the current user is safe here. + * + * @since $$next-version$$ + * + * @param array $widget_candidates Manifest candidates. + * @return array The candidates, minus commerce categories for readers who can't see them. + */ +function filter_registrable_widget_types_by_capability( $widget_candidates ) { + return remove_capability_gated_widget_types( + $widget_candidates, + current_user_can_view_commerce_analytics() + ); +} + +add_filter( REGISTRABLE_WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_registrable_widget_types_by_capability' ); diff --git a/projects/packages/premium-analytics/src/widget-modules.php b/projects/packages/premium-analytics/src/widget-modules.php index ec96a98df209..9a4f5a0bda3f 100644 --- a/projects/packages/premium-analytics/src/widget-modules.php +++ b/projects/packages/premium-analytics/src/widget-modules.php @@ -13,6 +13,7 @@ namespace Automattic\Jetpack\PremiumAnalytics; require_once __DIR__ . '/rest-namespace.php'; +require_once __DIR__ . '/capabilities.php'; /** * Register the `/wpcom/v2/widget-modules` REST route. @@ -26,9 +27,7 @@ function register_widget_modules_rest_route() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => __NAMESPACE__ . '\\get_widget_modules_response', - 'permission_callback' => static function () { - return current_user_can( 'manage_options' ); - }, + 'permission_callback' => __NAMESPACE__ . '\\current_user_can_view_analytics', ) ); } diff --git a/projects/packages/premium-analytics/tests/php/Analytics_Test.php b/projects/packages/premium-analytics/tests/php/Analytics_Test.php index 7071f0a73797..7c16cf35fcb5 100644 --- a/projects/packages/premium-analytics/tests/php/Analytics_Test.php +++ b/projects/packages/premium-analytics/tests/php/Analytics_Test.php @@ -192,6 +192,16 @@ public function test_is_dashboard_request_false_on_front_end() { $this->assertFalse( Analytics::is_dashboard_request() ); } + /** + * The menu is gated on the dashboard capability rather than manage_options, so a + * site that granted an editor view_stats keeps that editor's access to stats. + */ + public function test_register_admin_menu_uses_the_dashboard_capability() { + $menu_item = $this->register_admin_menu_without_build(); + + $this->assertSame( VIEW_ANALYTICS_CAPABILITY, $menu_item[1] ?? null ); + } + /** * With no caller override the label comes from the package itself, so nobody has * to translate it before the textdomain can load. @@ -385,13 +395,15 @@ function ( $function_name ) { } /** - * Grant manage_options to the (logged-out) test user. + * Give the (logged-out) test user what an administrator carries: manage_options, + * and the read primitive the dashboard capability maps to. * * @param array $caps Capabilities. * @return array */ public function grant_manage_options( $caps ) { $caps['manage_options'] = true; + $caps['read'] = true; return $caps; } diff --git a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php new file mode 100644 index 000000000000..ccc38aa04f61 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -0,0 +1,109 @@ +login_as( 'administrator' ); + + $this->assertTrue( current_user_can_view_analytics() ); + } + + /** + * The point of the issue: an editor the site granted view_stats keeps access. + */ + public function test_editor_granted_view_stats_can_view_analytics() { + $user_id = $this->login_as( 'editor' ); + $this->grant_view_stats_to( $user_id ); + + $this->assertTrue( current_user_can_view_analytics() ); + } + + /** + * An editor the site never granted view_stats gets nothing. + */ + public function test_plain_editor_cannot_view_analytics() { + $this->login_as( 'editor' ); + + $this->assertFalse( current_user_can_view_analytics() ); + } + + /** + * Logged out is not a reader. + */ + public function test_logged_out_user_cannot_view_analytics() { + wp_set_current_user( 0 ); + + $this->assertFalse( current_user_can_view_analytics() ); + } + + /** + * Capabilities other than ours pass through the mapping untouched. + */ + public function test_mapping_leaves_other_capabilities_alone() { + $this->assertSame( + array( 'edit_posts' ), + map_analytics_meta_caps( array( 'edit_posts' ), 'edit_posts', 1 ) + ); + } + + /** + * Commerce surfaces stay administrator-only: view_stats is not enough. + */ + public function test_view_stats_reader_cannot_view_commerce_analytics() { + $user_id = $this->login_as( 'editor' ); + $this->grant_view_stats_to( $user_id ); + + $this->assertTrue( current_user_can_view_analytics() ); + $this->assertFalse( current_user_can_view_commerce_analytics() ); + } + + /** + * Administrators keep the commerce surfaces. + */ + public function test_administrator_can_view_commerce_analytics() { + $this->login_as( 'administrator' ); + + $this->assertTrue( current_user_can_view_commerce_analytics() ); + } +} diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php index 490b25b02fa3..321850ea3dbb 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php @@ -50,6 +50,24 @@ public function test_default_layout_route_uses_wpcom_v2_namespace() { $this->assertArrayNotHasKey( self::LEGACY_ROUTE, $routes ); } + /** + * The dashboard fetches this route on boot, so it has to admit every reader + * the dashboard itself admits — not administrators only. + */ + public function test_default_layout_route_is_gated_on_the_dashboard_capability() { + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + + register_dashboard_default_layout_route(); + + $routes = rest_get_server()->get_routes(); + + $this->assertSame( + __NAMESPACE__ . '\\current_user_can_view_analytics', + $routes[ self::ROUTE ][0]['permission_callback'] + ); + } + /** * Non-Premium-Analytics dashboards are left untouched. */ diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php index bd5c360a7bbb..87071fbb5908 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php @@ -12,12 +12,15 @@ use WP_REST_Server; require_once __DIR__ . '/../../src/dashboard-sections.php'; +require_once __DIR__ . '/traits/trait-analytics-capabilities.php'; /** * Tests for Premium Analytics dashboard sections. */ class Dashboard_Section_Test extends BaseTestCase { + use Analytics_Capabilities_Trait; + /** * Counter for unique test user logins. * @@ -57,6 +60,11 @@ public function tear_down() { remove_all_actions( 'doing_it_wrong_run' ); remove_all_filters( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER ); + // Drops any per-user view_stats grant a test added, then puts back the + // package's own mapping, which is hooked on include and so only once per run. + remove_all_filters( 'map_meta_cap' ); + register_capabilities(); + wp_set_current_user( 0 ); parent::tear_down(); @@ -417,6 +425,7 @@ static function ( Dashboard_Section $section ) { */ public function test_registers_woocommerce_dashboard_section_when_available() { add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); + $this->set_admin_user(); register_default_dashboard_sections(); @@ -473,6 +482,62 @@ static function ( Dashboard_Section $section ) { ); } + /** + * Store data is only served to administrators, so a reader who reached the + * dashboard through view_stats is not offered the section at all. + */ + public function test_omits_woocommerce_dashboard_section_from_a_view_stats_reader() { + add_filter( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, '__return_true' ); + $user_id = $this->set_editor_user(); + $this->grant_view_stats_to( $user_id ); + + register_default_dashboard_sections(); + + $woocommerce = get_registered_dashboard_section( DASHBOARD_NAME, 'woocommerce/store' ); + + $this->assertFalse( $woocommerce->is_available() ); + $this->assertNotContains( + 'woocommerce/store', + array_map( + static function ( Dashboard_Section $section ) { + return $section->id; + }, + get_available_dashboard_sections( DASHBOARD_NAME ) + ) + ); + } + + /** + * That reader still gets the section routes: the dashboard itself is theirs. + */ + public function test_sections_route_serves_a_view_stats_reader() { + $user_id = $this->set_editor_user(); + $this->grant_view_stats_to( $user_id ); + + register_default_dashboard_sections(); + + $response = rest_get_server()->dispatch( + new WP_REST_Request( 'GET', '/wpcom/v2/dashboards/' . DASHBOARD_NAME . '/sections' ) + ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A user with neither capability gets nothing. + */ + public function test_sections_route_refuses_a_plain_editor() { + $this->set_editor_user(); + + register_default_dashboard_sections(); + + $response = rest_get_server()->dispatch( + new WP_REST_Request( 'GET', '/wpcom/v2/dashboards/' . DASHBOARD_NAME . '/sections' ) + ); + + $this->assertSame( 403, $response->get_status() ); + } + /** * Bootstrapping after init registers the default sections immediately. */ @@ -764,4 +829,26 @@ private function set_admin_user() { return $admin_id; } + + /** + * Set current user to an editor: a dashboard reader once granted view_stats, + * and never an administrator. + * + * @return int User ID. + */ + private function set_editor_user() { + ++self::$user_count; + + $editor_id = wp_insert_user( + array( + 'user_login' => 'jpa_dashboard_sections_editor_' . self::$user_count, + 'user_pass' => 'password', + 'role' => 'editor', + ) + ); + + wp_set_current_user( $editor_id ); + + return $editor_id; + } } diff --git a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php index 30df334ddea2..b700dd984ef1 100644 --- a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -25,6 +25,8 @@ * @covers ::Automattic\Jetpack\PremiumAnalytics\remove_dev_only_widget_types * @covers ::Automattic\Jetpack\PremiumAnalytics\filter_registrable_widget_types_by_plugin * @covers ::Automattic\Jetpack\PremiumAnalytics\remove_plugin_gated_widget_types + * @covers ::Automattic\Jetpack\PremiumAnalytics\filter_registrable_widget_types_by_capability + * @covers ::Automattic\Jetpack\PremiumAnalytics\remove_capability_gated_widget_types */ #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\get_available_widget_types' )] #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\get_widget_support_context' )] @@ -35,6 +37,8 @@ #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\remove_dev_only_widget_types' )] #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\filter_registrable_widget_types_by_plugin' )] #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\remove_plugin_gated_widget_types' )] +#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\filter_registrable_widget_types_by_capability' )] +#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\remove_capability_gated_widget_types' )] class Widget_Availability_Test extends BaseTestCase { /** @@ -258,6 +262,45 @@ public function test_uncategorized_widgets_pass_through() { ); } + /** + * Commerce widgets read through the proxy's administrator-only `analytics` + * prefix, so a reader who can't see store data is never offered them. + */ + public function test_commerce_widgets_removed_from_a_reader_without_store_access() { + $this->assertSame( + array( 'jpa/traffic-chart' ), + array_column( + remove_capability_gated_widget_types( $this->commerce_widget_candidates(), false ), + 'name' + ), + 'Every commerce category is dropped for a reader without store access.' + ); + } + + /** + * Administrators keep every category. + */ + public function test_commerce_widgets_kept_for_a_user_with_store_access() { + $this->assertSame( + $this->commerce_widget_candidates(), + remove_capability_gated_widget_types( $this->commerce_widget_candidates(), true ), + 'With store access no candidate is dropped.' + ); + } + + /** + * Candidates without a category are never capability-gated. + */ + public function test_uncategorized_widgets_are_not_capability_gated() { + $candidates = array( array( 'name' => 'jpa/no-category' ) ); + + $this->assertSame( + $candidates, + remove_capability_gated_widget_types( $candidates, false ), + 'A candidate without a category must not be capability-gated.' + ); + } + /** * The host callback removes File downloads on Atomic. */ diff --git a/projects/packages/premium-analytics/tests/php/Widget_Modules_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Modules_Test.php index e413574f5407..d0791fbb7b43 100644 --- a/projects/packages/premium-analytics/tests/php/Widget_Modules_Test.php +++ b/projects/packages/premium-analytics/tests/php/Widget_Modules_Test.php @@ -48,6 +48,24 @@ public function test_widget_modules_route_uses_wpcom_v2_namespace() { $this->assertArrayNotHasKey( self::LEGACY_ROUTE, $routes ); } + /** + * The dashboard fetches this route on boot, so it has to admit every reader + * the dashboard itself admits — not administrators only. + */ + public function test_widget_modules_route_is_gated_on_the_dashboard_capability() { + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + + register_widget_modules_rest_route(); + + $routes = rest_get_server()->get_routes(); + + $this->assertSame( + __NAMESPACE__ . '\\current_user_can_view_analytics', + $routes[ self::ROUTE ][0]['permission_callback'] + ); + } + /** * The route's callback works standalone: it hydrates the widget type * registry itself (ensure_widget_registry_ready()) rather than assuming a diff --git a/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php new file mode 100644 index 000000000000..4331adb7d0e6 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php @@ -0,0 +1,56 @@ + 'pa-' . $role, + 'user_pass' => 'password', + 'user_email' => 'pa-' . $role . '@example.com', + 'role' => $role, + ) + ); + + wp_set_current_user( $user_id ); + + return $user_id; + } + + /** + * Stands in for Stats' map_meta_cap, which the package under test does not boot. + * + * @param int $granted_user_id User who should hold `view_stats`. + * @return void + */ + protected function grant_view_stats_to( $granted_user_id ) { + add_filter( + 'map_meta_cap', + static function ( $caps, $cap, $user_id ) use ( $granted_user_id ) { + if ( 'view_stats' === $cap && (int) $granted_user_id === (int) $user_id ) { + return array( 'read' ); + } + + return $caps; + }, + 10, + 3 + ); + } +} From 28645eeb6e62bf5e3b346d57f741a32db5c2b496 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 14:00:56 +0800 Subject: [PATCH 02/10] Premium Analytics: hook the capability mapping from WordPress-aware entry points --- .../premium-analytics/src/capabilities.php | 14 +++++++------- .../premium-analytics/src/class-analytics.php | 8 ++++++-- .../src/class-dashboard-support-routes.php | 5 +++++ .../tests/php/Analytics_Test.php | 5 +++++ .../tests/php/Capabilities_Test.php | 11 ++++++++--- .../tests/php/Dashboard_Section_Test.php | 9 ++++++--- .../php/Dashboard_Support_Routes_Test.php | 19 +++++++++++++++++++ .../php/REST/Notices_Controller_Test.php | 13 +++++++++++++ 8 files changed, 69 insertions(+), 15 deletions(-) diff --git a/projects/packages/premium-analytics/src/capabilities.php b/projects/packages/premium-analytics/src/capabilities.php index ba2310d2c109..9859d1304e3d 100644 --- a/projects/packages/premium-analytics/src/capabilities.php +++ b/projects/packages/premium-analytics/src/capabilities.php @@ -21,11 +21,13 @@ /** * Hooks the dashboard's meta capability mapping. * - * Called at file scope below, not from an init entry point: WPCOM Simple calls - * Dashboard_Support_Routes::register() standalone, without ever reaching - * Analytics::init(), and those routes are gated on this capability. Every - * consumer requires this file, so registering on include is what keeps the two - * from drifting apart. Idempotent — add_filter() dedupes the same callback. + * Deliberately not called at file scope: this file is included while the + * Analytics class is autoloaded, which happens in contexts where WordPress + * itself isn't loaded, and add_filter() would be undefined there. Callers hook + * it from their own WordPress-aware entry points instead — both + * Analytics::init() paths, and Dashboard_Support_Routes, which WPCOM Simple + * boots standalone without ever reaching Analytics::init(). Idempotent, so + * overlapping callers are free to call it. * * @since $$next-version$$ * @@ -35,8 +37,6 @@ function register_capabilities() { add_filter( 'map_meta_cap', __NAMESPACE__ . '\\map_analytics_meta_caps', 10, 3 ); } -register_capabilities(); - /** * Maps the dashboard capability to the primitives that grant it. * diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index f664927621cc..6d790370c58d 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -14,8 +14,8 @@ use Automattic\Jetpack\PremiumAnalytics\Sync\Sync_Status_Tracker; use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills; -// Defines the capability the admin menu is registered with, and hooks its -// mapping on include. +// Defines the capability the admin menu is registered with. Declarations only — +// this runs on autoload, where WordPress may not be loaded yet. require_once __DIR__ . '/capabilities.php'; /** @@ -134,6 +134,10 @@ private static function apply_options( $options ) { * @return void */ private static function boot_shared_services() { + // Who may read the dashboard. Hooked on every platform, and ahead of the + // hooks that check it (admin_menu, rest_api_init). + register_capabilities(); + // Emit WooCommerce store events into the Woo pipeline (ClickHouse + proxy). WooCommerce_Analytics_Tracker::configure(); diff --git a/projects/packages/premium-analytics/src/class-dashboard-support-routes.php b/projects/packages/premium-analytics/src/class-dashboard-support-routes.php index baeaa0c4d739..df2334ba75fb 100644 --- a/projects/packages/premium-analytics/src/class-dashboard-support-routes.php +++ b/projects/packages/premium-analytics/src/class-dashboard-support-routes.php @@ -49,6 +49,11 @@ public static function boot_routes() { require_once __DIR__ . '/widget-modules.php'; require_once __DIR__ . '/dashboard-layout.php'; require_once __DIR__ . '/dashboard-sections.php'; + require_once __DIR__ . '/capabilities.php'; + + // These routes are gated on the dashboard capability, and WPCOM Simple boots + // this class standalone, without going through Analytics::init(). + register_capabilities(); register_widget_modules_rest_route(); register_dashboard_default_layout_route(); diff --git a/projects/packages/premium-analytics/tests/php/Analytics_Test.php b/projects/packages/premium-analytics/tests/php/Analytics_Test.php index 7c16cf35fcb5..cb6786b40c49 100644 --- a/projects/packages/premium-analytics/tests/php/Analytics_Test.php +++ b/projects/packages/premium-analytics/tests/php/Analytics_Test.php @@ -49,6 +49,7 @@ protected function tearDown(): void { remove_all_filters( 'jetpack_admin_js_script_data' ); remove_all_filters( 'rest_post_dispatch' ); remove_all_filters( 'jetpack_stats_transient_cleanup_prefixes' ); + remove_all_filters( 'map_meta_cap' ); $this->reset_analytics_init_state(); parent::tearDown(); } @@ -363,6 +364,10 @@ public function test_missing_build_notice_explains_itself_under_the_menu_label() private function register_admin_menu_without_build() { $GLOBALS['menu'] = array(); + // Hooked by boot_shared_services() in production; add_menu_page() resolves the + // dashboard capability, and an unmapped one would skip the page's render hook. + register_capabilities(); + add_filter( 'user_has_cap', array( $this, 'grant_manage_options' ) ); $this->capture_doing_it_wrong(); Analytics::register_admin_menu(); diff --git a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php index ccc38aa04f61..1eb5bf8c72c9 100644 --- a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -28,12 +28,17 @@ class Capabilities_Test extends BaseTestCase { use Analytics_Capabilities_Trait; /** - * Drop this test's stand-in for the Stats mapping, then put back the mapping - * under test — capabilities.php hooks it on include, which is once per run. + * Hook the mapping under test, the way a WordPress-aware entry point would. + */ + public function set_up() { + register_capabilities(); + } + + /** + * Drop the mapping and this test's stand-in for the Stats one. */ public function tear_down() { remove_all_filters( 'map_meta_cap' ); - register_capabilities(); wp_set_current_user( 0 ); parent::tear_down(); diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php index 87071fbb5908..703c17988ef4 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php @@ -44,6 +44,10 @@ public function set_up() { global $wp_rest_server; $wp_rest_server = new WP_REST_Server(); register_dashboard_sections_rest_routes(); + + // Hooked by the package's entry points in production; the routes under test + // are gated on the capability it maps. + register_capabilities(); } /** @@ -60,10 +64,9 @@ public function tear_down() { remove_all_actions( 'doing_it_wrong_run' ); remove_all_filters( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER ); - // Drops any per-user view_stats grant a test added, then puts back the - // package's own mapping, which is hooked on include and so only once per run. + // Drops the package's mapping along with any per-user view_stats grant a + // test added; set_up() hooks the mapping again. remove_all_filters( 'map_meta_cap' ); - register_capabilities(); wp_set_current_user( 0 ); diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php index 0f45058a2115..b85942cc4ce8 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php @@ -23,6 +23,7 @@ protected function tearDown(): void { $wp_rest_server = null; remove_all_actions( 'rest_api_init' ); remove_all_actions( 'init' ); + remove_all_filters( 'map_meta_cap' ); parent::tearDown(); } @@ -66,4 +67,22 @@ public function test_register_is_safe_to_call_twice() { $this->assertArrayHasKey( '/wpcom/v2/widget-modules', $routes ); $this->assertCount( 1, $routes['/wpcom/v2/widget-modules'] ); } + + /** + * Booting standalone also maps the capability these routes are gated on. + * WPCOM Simple takes this path without ever calling Analytics::init(), so + * without the mapping every request to them would be refused. + */ + public function test_boot_routes_maps_the_dashboard_capability() { + Dashboard_Support_Routes::register(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + do_action( 'init' ); + do_action( 'rest_api_init' ); + + $this->assertNotFalse( + has_filter( 'map_meta_cap', __NAMESPACE__ . '\\map_analytics_meta_caps' ) + ); + } } diff --git a/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php b/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php index e144b9a965e0..9b09773a6bf7 100644 --- a/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php +++ b/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php @@ -33,12 +33,25 @@ public function set_up() { parent::set_up(); $this->controller = new Notices_Controller(); + // Hooked alongside this controller by Analytics::init() in production; the + // route's permission check reads the capability it maps. + \Automattic\Jetpack\PremiumAnalytics\register_capabilities(); + global $wp_rest_server; $wp_rest_server = new WP_REST_Server(); add_action( 'rest_api_init', array( $this->controller, 'register_routes' ) ); do_action( 'rest_api_init' ); } + /** + * Drop the capability mapping this test hooked. + */ + public function tear_down() { + remove_all_filters( 'map_meta_cap' ); + + parent::tear_down(); + } + public function test_registers_notices_route_with_read_and_write_methods() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( self::ROUTE, $routes ); From e4542326a6f924f48fcf3d316fdb9169f53551d4 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 14:13:08 +0800 Subject: [PATCH 03/10] Premium Analytics: gate every analytics-prefix widget category, not just commerce --- .../premium-analytics/src/capabilities.php | 29 +++++++++--- .../src/dashboard-sections.php | 2 +- .../src/widget-availability.php | 36 ++++++++------- .../tests/php/Analytics_Test.php | 37 +++++++++++++++- .../tests/php/Capabilities_Test.php | 34 ++++++++++---- .../tests/php/Dashboard_Section_Test.php | 2 +- .../php/Dashboard_Support_Routes_Test.php | 2 +- .../php/REST/Notices_Controller_Test.php | 2 +- .../tests/php/Widget_Availability_Test.php | 37 ++++++++++++---- .../traits/trait-analytics-capabilities.php | 44 ++++++++++++++----- 10 files changed, 169 insertions(+), 56 deletions(-) diff --git a/projects/packages/premium-analytics/src/capabilities.php b/projects/packages/premium-analytics/src/capabilities.php index 9859d1304e3d..8c3078c94bf8 100644 --- a/projects/packages/premium-analytics/src/capabilities.php +++ b/projects/packages/premium-analytics/src/capabilities.php @@ -37,6 +37,23 @@ function register_capabilities() { add_filter( 'map_meta_cap', __NAMESPACE__ . '\\map_analytics_meta_caps', 10, 3 ); } +/** + * Unhooks the mapping registered by register_capabilities(). + * + * The counterpart exists so a caller — a test tearing down between cases, or a + * host unwinding the package — can drop this one filter by name instead of + * reaching for remove_all_filters( 'map_meta_cap' ), which would also take out + * whatever else is hooked there (the Stats package maps `view_stats` on that + * same filter). + * + * @since $$next-version$$ + * + * @return void + */ +function unregister_capabilities() { + remove_filter( 'map_meta_cap', __NAMESPACE__ . '\\map_analytics_meta_caps', 10 ); +} + /** * Maps the dashboard capability to the primitives that grant it. * @@ -77,16 +94,18 @@ function current_user_can_view_analytics() { } /** - * Whether the current user may read the commerce surfaces. + * Whether the current user may read data served by the proxy's `analytics` prefix. * - * Store data is served by the proxy's `analytics` prefix, which requires - * `manage_options`; a `view_stats` reader would only get 403s back, so the - * store section and its widget categories are hidden from them instead. + * The store section and every widget backed by that prefix are hidden from + * readers who fail this, since all they could collect is 403s. The capability + * restated here is the one {@see \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller} + * enforces for the prefix; Capabilities_Test pins the two together so loosening + * the proxy can't silently leave these surfaces hidden. * * @since $$next-version$$ * * @return bool */ -function current_user_can_view_commerce_analytics() { +function current_user_can_read_analytics_prefix() { return current_user_can( 'manage_options' ); } diff --git a/projects/packages/premium-analytics/src/dashboard-sections.php b/projects/packages/premium-analytics/src/dashboard-sections.php index b63fb85e31e7..cbc2b3181c31 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -79,7 +79,7 @@ function is_woocommerce_dashboard_section_available() { * @return bool */ function is_woocommerce_dashboard_section_available_to_current_user() { - return is_woocommerce_dashboard_section_available() && current_user_can_view_commerce_analytics(); + return is_woocommerce_dashboard_section_available() && current_user_can_read_analytics_prefix(); } /** diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index b4de681ece1f..64608716554c 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -33,12 +33,16 @@ const WOOCOMMERCE_BOOKINGS_WIDGET_CATEGORIES = array( 'bookings' ); /** - * Widget categories whose data the proxy only serves to administrators. - * - * Every commerce category, whichever plugin backs it: they all read through the - * proxy's `analytics` prefix. + * Widget categories served by the proxy's `analytics` prefix. + * + * The membership rule is the data source, not the subject matter: every + * category here reaches WPCOM through `analytics/reports/…`, which + * {@see \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller} + * gates on `manage_options`. That covers the commerce categories, and + * `visitors`, whose two widgets read `sessions/…` from the same prefix + * without a plugin gate of their own. */ -const COMMERCE_WIDGET_CATEGORIES = array( 'store', 'orders', 'coupons', 'bookings' ); +const ANALYTICS_PREFIX_WIDGET_CATEGORIES = array( 'store', 'orders', 'coupons', 'bookings', 'visitors' ); /** * Removes developer-only candidates in production. @@ -165,18 +169,18 @@ function filter_registrable_widget_types_by_plugin( $widget_candidates ) { add_filter( REGISTRABLE_WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_registrable_widget_types_by_plugin' ); /** - * Removes commerce candidates the reader could not load data for anyway. + * Removes candidates the reader could not load data for anyway. * * Split from the hook callback so both branches are testable without a user. * * @since $$next-version$$ * - * @param array $widget_candidates Manifest candidates, each with a `category`. - * @param bool $can_view_commerce Whether the reader may see store data. - * @return array The candidates, minus commerce categories for readers who can't. + * @param array $widget_candidates Manifest candidates, each with a `category`. + * @param bool $can_read_the_prefix Whether the reader may read the `analytics` prefix. + * @return array The candidates, minus that prefix's categories for readers who can't. */ -function remove_capability_gated_widget_types( $widget_candidates, $can_view_commerce ) { - if ( $can_view_commerce ) { +function remove_capability_gated_widget_types( $widget_candidates, $can_read_the_prefix ) { + if ( $can_read_the_prefix ) { return $widget_candidates; } @@ -184,28 +188,28 @@ function remove_capability_gated_widget_types( $widget_candidates, $can_view_com array_filter( $widget_candidates, static function ( $widget ) { - return ! in_array( $widget['category'] ?? '', COMMERCE_WIDGET_CATEGORIES, true ); + return ! in_array( $widget['category'] ?? '', ANALYTICS_PREFIX_WIDGET_CATEGORIES, true ); } ) ); } /** - * Registry-time callback: hides commerce widgets from readers without store access. + * Registry-time callback: hides widgets whose data the reader cannot fetch. * - * A `view_stats` reader reaching a store widget would only collect 403s from the + * A `view_stats` reader reaching one of them would only collect 403s from the * proxy's `analytics` prefix, so the types are never registered for them. The * registry is request-scoped, so filtering on the current user is safe here. * * @since $$next-version$$ * * @param array $widget_candidates Manifest candidates. - * @return array The candidates, minus commerce categories for readers who can't see them. + * @return array The candidates, minus that prefix's categories for readers who can't see them. */ function filter_registrable_widget_types_by_capability( $widget_candidates ) { return remove_capability_gated_widget_types( $widget_candidates, - current_user_can_view_commerce_analytics() + current_user_can_read_analytics_prefix() ); } diff --git a/projects/packages/premium-analytics/tests/php/Analytics_Test.php b/projects/packages/premium-analytics/tests/php/Analytics_Test.php index cb6786b40c49..72e5827c8d33 100644 --- a/projects/packages/premium-analytics/tests/php/Analytics_Test.php +++ b/projects/packages/premium-analytics/tests/php/Analytics_Test.php @@ -49,7 +49,7 @@ protected function tearDown(): void { remove_all_filters( 'jetpack_admin_js_script_data' ); remove_all_filters( 'rest_post_dispatch' ); remove_all_filters( 'jetpack_stats_transient_cleanup_prefixes' ); - remove_all_filters( 'map_meta_cap' ); + unregister_capabilities(); $this->reset_analytics_init_state(); parent::tearDown(); } @@ -95,6 +95,41 @@ public function test_init_registers_local_proxy_by_default() { $this->assertNotFalse( has_filter( 'jetpack_stats_transient_cleanup_prefixes' ) ); } + /** + * Both init paths map the capability the admin menu and the dashboard routes + * are gated on. Nothing else hooks it for them, so without this the menu is + * registered with a capability nobody holds — including administrators. + * + * @param bool $wpcom_simple Whether to boot the WordPress.com Simple path. + * @dataProvider provide_init_entry_points + */ + #[DataProvider( 'provide_init_entry_points' )] + public function test_init_maps_the_dashboard_capability( $wpcom_simple ) { + $this->reset_analytics_init_state(); + + if ( $wpcom_simple ) { + Analytics::init_wpcom_simple(); + } else { + Analytics::init(); + } + + $this->assertNotFalse( + has_filter( 'map_meta_cap', __NAMESPACE__ . '\\map_analytics_meta_caps' ) + ); + } + + /** + * The two platform entry points. + * + * @return array + */ + public static function provide_init_entry_points() { + return array( + 'connected site' => array( false ), + 'wpcom simple' => array( true ), + ); + } + /** * WordPress.com Simple mode skips local proxy registration. */ diff --git a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php index 1eb5bf8c72c9..5112c679e127 100644 --- a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -17,12 +17,12 @@ * @covers ::Automattic\Jetpack\PremiumAnalytics\map_analytics_meta_caps * @covers ::Automattic\Jetpack\PremiumAnalytics\register_capabilities * @covers ::Automattic\Jetpack\PremiumAnalytics\current_user_can_view_analytics - * @covers ::Automattic\Jetpack\PremiumAnalytics\current_user_can_view_commerce_analytics + * @covers ::Automattic\Jetpack\PremiumAnalytics\current_user_can_read_analytics_prefix */ #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\map_analytics_meta_caps' )] #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\register_capabilities' )] #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\current_user_can_view_analytics' )] -#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\current_user_can_view_commerce_analytics' )] +#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\current_user_can_read_analytics_prefix' )] class Capabilities_Test extends BaseTestCase { use Analytics_Capabilities_Trait; @@ -38,7 +38,7 @@ public function set_up() { * Drop the mapping and this test's stand-in for the Stats one. */ public function tear_down() { - remove_all_filters( 'map_meta_cap' ); + $this->reset_analytics_capabilities(); wp_set_current_user( 0 ); parent::tear_down(); @@ -93,22 +93,38 @@ public function test_mapping_leaves_other_capabilities_alone() { } /** - * Commerce surfaces stay administrator-only: view_stats is not enough. + * Surfaces backed by the `analytics` prefix stay administrator-only: reaching + * the dashboard through view_stats is not enough to read that prefix. */ - public function test_view_stats_reader_cannot_view_commerce_analytics() { + public function test_view_stats_reader_cannot_read_the_analytics_prefix() { $user_id = $this->login_as( 'editor' ); $this->grant_view_stats_to( $user_id ); $this->assertTrue( current_user_can_view_analytics() ); - $this->assertFalse( current_user_can_view_commerce_analytics() ); + $this->assertFalse( current_user_can_read_analytics_prefix() ); } /** - * Administrators keep the commerce surfaces. + * Administrators keep them. */ - public function test_administrator_can_view_commerce_analytics() { + public function test_administrator_can_read_the_analytics_prefix() { $this->login_as( 'administrator' ); - $this->assertTrue( current_user_can_view_commerce_analytics() ); + $this->assertTrue( current_user_can_read_analytics_prefix() ); + } + + /** + * The helper restates the capability the proxy enforces for the `analytics` + * prefix. Pinned here so loosening the proxy can't quietly leave the surfaces + * this hides stranded behind a stricter check than their own data needs. + */ + public function test_analytics_prefix_helper_matches_the_proxy_capability() { + // getValue() reads a private constant without any accessibility dance. + $prefixes = ( new \ReflectionClassConstant( + \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller::class, + 'PREFIX_CONFIG' + ) )->getValue(); + + $this->assertSame( 'manage_options', $prefixes['analytics']['capability'] ); } } diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php index 703c17988ef4..e934685a7c08 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php @@ -66,7 +66,7 @@ public function tear_down() { // Drops the package's mapping along with any per-user view_stats grant a // test added; set_up() hooks the mapping again. - remove_all_filters( 'map_meta_cap' ); + $this->reset_analytics_capabilities(); wp_set_current_user( 0 ); diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php index b85942cc4ce8..d1db91fe40f9 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php @@ -23,7 +23,7 @@ protected function tearDown(): void { $wp_rest_server = null; remove_all_actions( 'rest_api_init' ); remove_all_actions( 'init' ); - remove_all_filters( 'map_meta_cap' ); + unregister_capabilities(); parent::tearDown(); } diff --git a/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php b/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php index 9b09773a6bf7..ad2aaffca4d4 100644 --- a/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php +++ b/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php @@ -47,7 +47,7 @@ public function set_up() { * Drop the capability mapping this test hooked. */ public function tear_down() { - remove_all_filters( 'map_meta_cap' ); + \Automattic\Jetpack\PremiumAnalytics\unregister_capabilities(); parent::tear_down(); } diff --git a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php index b700dd984ef1..b6c94500cfbb 100644 --- a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -103,6 +103,24 @@ private function commerce_widget_candidates() { ); } + /** + * Candidate set spanning every category the `analytics` prefix serves, plus + * one served by another prefix. + * + * @return array[] List of widget candidates. + */ + private function analytics_prefix_widget_candidates() { + return array_merge( + $this->commerce_widget_candidates(), + array( + array( + 'name' => 'jpa/visitors-over-time', + 'category' => 'visitors', + ), + ) + ); + } + /** * Filters the standard candidates with explicit host context. * @@ -263,28 +281,29 @@ public function test_uncategorized_widgets_pass_through() { } /** - * Commerce widgets read through the proxy's administrator-only `analytics` - * prefix, so a reader who can't see store data is never offered them. + * Every category the `analytics` prefix serves — the commerce ones and + * `visitors` — is dropped for a reader who can't read that prefix, since all + * they could collect from those widgets is 403s. */ - public function test_commerce_widgets_removed_from_a_reader_without_store_access() { + public function test_analytics_prefix_widgets_removed_from_a_reader_without_access() { $this->assertSame( array( 'jpa/traffic-chart' ), array_column( - remove_capability_gated_widget_types( $this->commerce_widget_candidates(), false ), + remove_capability_gated_widget_types( $this->analytics_prefix_widget_candidates(), false ), 'name' ), - 'Every commerce category is dropped for a reader without store access.' + 'Only the category served by another prefix survives.' ); } /** * Administrators keep every category. */ - public function test_commerce_widgets_kept_for_a_user_with_store_access() { + public function test_analytics_prefix_widgets_kept_for_a_user_with_access() { $this->assertSame( - $this->commerce_widget_candidates(), - remove_capability_gated_widget_types( $this->commerce_widget_candidates(), true ), - 'With store access no candidate is dropped.' + $this->analytics_prefix_widget_candidates(), + remove_capability_gated_widget_types( $this->analytics_prefix_widget_candidates(), true ), + 'With prefix access no candidate is dropped.' ); } diff --git a/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php index 4331adb7d0e6..33cb2f67c653 100644 --- a/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php +++ b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php @@ -12,6 +12,14 @@ */ trait Analytics_Capabilities_Trait { + /** + * The stand-in mapping hooked by grant_view_stats_to(), kept so tear-down can + * drop it by reference rather than clearing the whole `map_meta_cap` filter. + * + * @var callable|null + */ + private $granted_view_stats_filter = null; + /** * Creates a user of the given role and makes it current. * @@ -40,17 +48,29 @@ protected function login_as( $role ) { * @return void */ protected function grant_view_stats_to( $granted_user_id ) { - add_filter( - 'map_meta_cap', - static function ( $caps, $cap, $user_id ) use ( $granted_user_id ) { - if ( 'view_stats' === $cap && (int) $granted_user_id === (int) $user_id ) { - return array( 'read' ); - } - - return $caps; - }, - 10, - 3 - ); + $this->granted_view_stats_filter = static function ( $caps, $cap, $user_id ) use ( $granted_user_id ) { + if ( 'view_stats' === $cap && (int) $granted_user_id === (int) $user_id ) { + return array( 'read' ); + } + + return $caps; + }; + + add_filter( 'map_meta_cap', $this->granted_view_stats_filter, 10, 3 ); + } + + /** + * Drops the package's mapping and this trait's stand-in for the Stats one, + * leaving anything else hooked on `map_meta_cap` in place. + * + * @return void + */ + protected function reset_analytics_capabilities() { + unregister_capabilities(); + + if ( null !== $this->granted_view_stats_filter ) { + remove_filter( 'map_meta_cap', $this->granted_view_stats_filter, 10 ); + $this->granted_view_stats_filter = null; + } } } From c721e5ead347c9929c78661513d7b99552614eaa Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 14:24:01 +0800 Subject: [PATCH 04/10] Premium Analytics: pin the analytics-prefix gate to the proxy's own decision --- .../tests/php/Capabilities_Test.php | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php index 5112c679e127..3386ebda8cb7 100644 --- a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -7,8 +7,10 @@ namespace Automattic\Jetpack\PremiumAnalytics; +use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller; use PHPUnit\Framework\Attributes\CoversFunction; use WorDBless\BaseTestCase; +use WP_REST_Request; require_once __DIR__ . '/../../src/capabilities.php'; require_once __DIR__ . '/traits/trait-analytics-capabilities.php'; @@ -115,16 +117,36 @@ public function test_administrator_can_read_the_analytics_prefix() { /** * The helper restates the capability the proxy enforces for the `analytics` - * prefix. Pinned here so loosening the proxy can't quietly leave the surfaces - * this hides stranded behind a stricter check than their own data needs. + * prefix, so the two are pinned to each other rather than to a literal: a + * proxy that loosens without the helper following would leave these surfaces + * hidden from readers whose data they could now serve, and the reverse would + * offer widgets that answer 403. + * + * Asserted through check_data_permission() rather than by reading + * PREFIX_CONFIG, so what's compared is the decision each side actually + * reaches for the same user. Api_Proxy_Controller_Test pins the proxy's own + * capability to `manage_options` in its endpoint matrix. */ public function test_analytics_prefix_helper_matches_the_proxy_capability() { - // getValue() reads a private constant without any accessibility dance. - $prefixes = ( new \ReflectionClassConstant( - \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller::class, - 'PREFIX_CONFIG' - ) )->getValue(); + $controller = new Api_Proxy_Controller(); + $request = new WP_REST_Request( 'GET', '/jetpack-premium-analytics/v1/proxy/v2/analytics/reports/orders' ); + $request->set_param( 'endpoint', 'analytics/reports/orders' ); - $this->assertSame( 'manage_options', $prefixes['analytics']['capability'] ); + $reader = $this->login_as( 'editor' ); + $this->grant_view_stats_to( $reader ); + + $this->assertSame( + $controller->check_data_permission( $request ), + current_user_can_read_analytics_prefix(), + 'A view_stats reader must be refused by the proxy and by the helper that hides its surfaces.' + ); + + $this->login_as( 'administrator' ); + + $this->assertSame( + $controller->check_data_permission( $request ), + current_user_can_read_analytics_prefix(), + 'An administrator must be admitted by both.' + ); } } From 35ff0c9d91c43054b8f9858599f777193e741f95 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 14:50:35 +0800 Subject: [PATCH 05/10] Premium Analytics: serve store reports to view_woocommerce_reports users --- .phpcs.xml.dist | 3 ++ projects/packages/premium-analytics/AGENTS.md | 20 +++++------ .../changelog/wooa7s-1779-view-stats-access | 2 +- .../src/REST/class-api-proxy-controller.php | 6 +++- .../Export/class-csv-export-controller.php | 11 +++--- .../premium-analytics/src/capabilities.php | 4 ++- .../tests/php/Capabilities_Test.php | 27 +++++++++++++- .../php/REST/Api_Proxy_Controller_Test.php | 36 +++++++++++++++---- .../Export/CSVExportController_Test.php | 8 ++--- .../traits/trait-analytics-capabilities.php | 18 ++++++++++ 10 files changed, 106 insertions(+), 29 deletions(-) diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 4ceff1da3cae..a7184c901464 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -36,7 +36,10 @@ + + + diff --git a/projects/packages/premium-analytics/AGENTS.md b/projects/packages/premium-analytics/AGENTS.md index d163ff0fd947..c0d82a7a6ec2 100644 --- a/projects/packages/premium-analytics/AGENTS.md +++ b/projects/packages/premium-analytics/AGENTS.md @@ -68,16 +68,16 @@ Two local REST surfaces; almost all data comes from WordPress.com via one agnost - `` must be allowlisted in `PREFIX_CONFIG` or the route 404s. This is the security boundary — the blog token is only forwarded for these. -| Prefix | Capability | Writes (POST) | -| ----------------------------------------------------------------- | ------------------ | ------------------------------- | -| `analytics` (Woo store reports) | `manage_options` | — | -| `stats` | `view_stats` | `stats/referrers/spam/` | -| `wordads` | `activate_wordads` | — | -| `subscribers` / `site-has-never-published-post` / `jetpack-stats` | `view_stats` | — | -| `jetpack-stats-dashboard` | `view_stats` | whole prefix (busts read cache) | -| `commercial-classification` | `view_stats` | exact path | -| `upgrades` (not under `/sites/`) | `view_stats` | — | -| `posts` (pattern-constrained: only `/likes`) | `view_stats` | — | +| Prefix | Capability | Writes (POST) | +| ----------------------------------------------------------------- | -------------------------- | ------------------------------- | +| `analytics` (Woo store reports) | `view_woocommerce_reports` | — | +| `stats` | `view_stats` | `stats/referrers/spam/` | +| `wordads` | `activate_wordads` | — | +| `subscribers` / `site-has-never-published-post` / `jetpack-stats` | `view_stats` | — | +| `jetpack-stats-dashboard` | `view_stats` | whole prefix (busts read cache) | +| `commercial-classification` | `view_stats` | exact path | +| `upgrades` (not under `/sites/`) | `view_stats` | — | +| `posts` (pattern-constrained: only `/likes`) | `view_stats` | — | `manage_options` is always accepted too. `POST` is rejected (`405 rest_read_only`) outside the Writes column. Query params pass through except control params (`endpoint`, `version`, diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1779-view-stats-access b/projects/packages/premium-analytics/changelog/wooa7s-1779-view-stats-access index 8c5c161bd1eb..dfb08e4005ba 100644 --- a/projects/packages/premium-analytics/changelog/wooa7s-1779-view-stats-access +++ b/projects/packages/premium-analytics/changelog/wooa7s-1779-view-stats-access @@ -1,4 +1,4 @@ Significance: minor Type: added -Allow users with the view_stats capability to open the Analytics dashboard. +Allow users with the view_stats capability to open the Analytics dashboard, and shop managers to read the store reports. diff --git a/projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php b/projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php index 3bdf6be8d689..13e962f1a2f6 100644 --- a/projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php +++ b/projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php @@ -119,7 +119,11 @@ class Api_Proxy_Controller extends WP_REST_Controller { * @var array> */ private const PREFIX_CONFIG = array( - 'analytics' => array( 'capability' => 'manage_options' ), + // Store reports, gated as WooCommerce gates its own Analytics screens, which + // shop managers can read. woocommerce-analytics made the same move away from + // manage_options (WOOA7S-551); requiring it here would lock those users out of + // reports they already have elsewhere. + 'analytics' => array( 'capability' => 'view_woocommerce_reports' ), 'stats' => array( 'capability' => 'view_stats', 'writes' => array( 'stats/referrers/spam/' ), diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php index 139e13d07f11..67c692829264 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php @@ -20,6 +20,9 @@ use WP_Error; use WP_REST_Request; use WP_REST_Response; +use function Automattic\Jetpack\PremiumAnalytics\current_user_can_read_analytics_prefix; + +require_once __DIR__ . '/../../capabilities.php'; /** * CSV Export Controller class. @@ -135,14 +138,14 @@ public function register_routes(): void { /** * Check if user has permission to export reports. * - * Must match the capability the analytics proxy enforces (`manage_options` for the - * `analytics` prefix in Api_Proxy_Controller); otherwise the route would advertise - * access the async data fetch cannot honor, scheduling a job that then fails. + * Every report exported here is fetched through the proxy's `analytics` prefix, so + * this defers to the same check rather than restating a capability: advertising + * access the async fetch cannot honor would schedule a job that then fails. * * @return bool True if user has permission. */ public function check_permission(): bool { - return current_user_can( 'manage_options' ); + return current_user_can_read_analytics_prefix(); } /** diff --git a/projects/packages/premium-analytics/src/capabilities.php b/projects/packages/premium-analytics/src/capabilities.php index 8c3078c94bf8..a59f5d28b7c0 100644 --- a/projects/packages/premium-analytics/src/capabilities.php +++ b/projects/packages/premium-analytics/src/capabilities.php @@ -107,5 +107,7 @@ function current_user_can_view_analytics() { * @return bool */ function current_user_can_read_analytics_prefix() { - return current_user_can( 'manage_options' ); + // The proxy accepts manage_options for every prefix, so administrators pass + // even where WooCommerce never registered its capabilities. + return current_user_can( 'manage_options' ) || current_user_can( 'view_woocommerce_reports' ); } diff --git a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php index 3386ebda8cb7..9c26d97b27a3 100644 --- a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -125,7 +125,7 @@ public function test_administrator_can_read_the_analytics_prefix() { * Asserted through check_data_permission() rather than by reading * PREFIX_CONFIG, so what's compared is the decision each side actually * reaches for the same user. Api_Proxy_Controller_Test pins the proxy's own - * capability to `manage_options` in its endpoint matrix. + * capability to `view_woocommerce_reports` in its endpoint matrix. */ public function test_analytics_prefix_helper_matches_the_proxy_capability() { $controller = new Api_Proxy_Controller(); @@ -148,5 +148,30 @@ public function test_analytics_prefix_helper_matches_the_proxy_capability() { current_user_can_read_analytics_prefix(), 'An administrator must be admitted by both.' ); + + // WooCommerce's own capability, held by shop managers, who have no + // manage_options. WorDBless has no shop_manager role, so grant the + // capability the role would carry. + $shop_manager = $this->login_as( 'subscriber' ); + $this->grant_capability_to( $shop_manager, 'view_woocommerce_reports' ); + + $this->assertTrue( current_user_can_read_analytics_prefix() ); + $this->assertSame( + $controller->check_data_permission( $request ), + current_user_can_read_analytics_prefix(), + 'A WooCommerce report viewer must be admitted by both.' + ); + } + + /** + * Store access is not dashboard access: the capability that opens the store + * reports says nothing about who may read stats. + */ + public function test_woocommerce_report_viewer_is_not_a_dashboard_reader() { + $shop_manager = $this->login_as( 'subscriber' ); + $this->grant_capability_to( $shop_manager, 'view_woocommerce_reports' ); + + $this->assertTrue( current_user_can_read_analytics_prefix() ); + $this->assertFalse( current_user_can_view_analytics() ); } } diff --git a/projects/packages/premium-analytics/tests/php/REST/Api_Proxy_Controller_Test.php b/projects/packages/premium-analytics/tests/php/REST/Api_Proxy_Controller_Test.php index 2316402d7810..16b3b19ed19d 100644 --- a/projects/packages/premium-analytics/tests/php/REST/Api_Proxy_Controller_Test.php +++ b/projects/packages/premium-analytics/tests/php/REST/Api_Proxy_Controller_Test.php @@ -49,7 +49,7 @@ public function test_analytics_is_served_by_the_data_route() { $this->assertStringContainsString( 'analytics', $this->data_route_key() ); } - public function test_analytics_requires_manage_options() { + public function test_analytics_requires_the_woocommerce_reports_capability() { $user_id = wp_insert_user( array( 'user_login' => 'jpa_stats_only', @@ -61,12 +61,33 @@ public function test_analytics_requires_manage_options() { $user->add_cap( 'view_stats' ); wp_set_current_user( $user_id ); - // view_stats reaches stats data but NOT the premium analytics surface. + // view_stats reaches stats data but NOT the store reports. $this->assertFalse( $this->controller->check_data_permission( $this->build_data_request( 'GET', 'analytics/reports/totals' ) ) ); $this->assertTrue( $this->controller->check_data_permission( $this->build_data_request( 'GET', 'stats/top-posts' ) ) ); } - public function test_permission_denied_without_manage_options() { + /** + * Shop managers hold view_woocommerce_reports without manage_options, and read + * these same reports on WooCommerce's own Analytics screens. + */ + public function test_analytics_is_served_to_a_woocommerce_report_viewer() { + $user_id = wp_insert_user( + array( + 'user_login' => 'jpa_shop_manager', + 'user_pass' => 'password', + 'role' => 'subscriber', + ) + ); + $user = new \WP_User( $user_id ); + $user->add_cap( 'view_woocommerce_reports' ); + wp_set_current_user( $user_id ); + + $this->assertTrue( $this->controller->check_data_permission( $this->build_data_request( 'GET', 'analytics/reports/totals' ) ) ); + // Store access is not stats access. + $this->assertFalse( $this->controller->check_data_permission( $this->build_data_request( 'GET', 'stats/top-posts' ) ) ); + } + + public function test_permission_denied_without_any_report_capability() { wp_set_current_user( 0 ); $this->assertFalse( $this->controller->check_data_permission( $this->build_data_request( 'GET', 'analytics/reports/totals' ) ) ); } @@ -247,7 +268,8 @@ public function test_stats_permission_granted_for_view_stats_capability() { $user->add_cap( 'view_stats' ); wp_set_current_user( $user_id ); - // view_stats reaches stats, but not the WordAds (activate_wordads) or analytics (manage_options) tiers. + // view_stats reaches stats, but not the WordAds (activate_wordads) or store + // report (view_woocommerce_reports) tiers. $this->assertTrue( $this->controller->check_data_permission( $this->build_data_request( 'GET', 'stats/top-posts' ) ) ); $this->assertFalse( $this->controller->check_data_permission( $this->build_data_request( 'GET', 'wordads/earnings' ) ) ); $this->assertFalse( $this->controller->check_data_permission( $this->build_data_request( 'GET', 'analytics/reports/totals' ) ) ); @@ -684,11 +706,11 @@ public function test_endpoint_behavior_matrix( string $endpoint, string $capabil public static function data_endpoint_matrix(): array { $stats = 'view_stats'; $wordads = 'activate_wordads'; - $admin = 'manage_options'; + $store = 'view_woocommerce_reports'; return array( - // Analytics (manage_options). - 'analytics report' => array( 'analytics/reports/totals', $admin, false, '/sites/%d/analytics/reports/totals' ), + // Store reports (view_woocommerce_reports). + 'analytics report' => array( 'analytics/reports/totals', $store, false, '/sites/%d/analytics/reports/totals' ), // Site stats + every /stats/* read family (v1.1, view_stats). 'site stats' => array( 'stats', $stats, false, '/sites/%d/stats' ), diff --git a/projects/packages/premium-analytics/tests/php/Reports/Export/CSVExportController_Test.php b/projects/packages/premium-analytics/tests/php/Reports/Export/CSVExportController_Test.php index d716a519bd0e..a345acddcbc3 100644 --- a/projects/packages/premium-analytics/tests/php/Reports/Export/CSVExportController_Test.php +++ b/projects/packages/premium-analytics/tests/php/Reports/Export/CSVExportController_Test.php @@ -131,9 +131,9 @@ public function test_validate_to_date_rejects_future_site_local_date() { } } - public function test_check_permission_requires_manage_options() { - // A shop-manager-style user (WooCommerce caps but not manage_options) must be denied, - // because the analytics proxy the export fetch relies on requires manage_options. + public function test_check_permission_matches_the_store_report_capability() { + // A shop-manager-style user (WooCommerce caps but not manage_options) is allowed, + // because the analytics proxy the export fetch relies on takes the same capability. $user_id = wp_insert_user( array( 'user_login' => 'exporter', @@ -151,7 +151,7 @@ public function test_check_permission_requires_manage_options() { return $allcaps; }; add_filter( 'user_has_cap', $grant_wc ); - $this->assertFalse( $this->controller->check_permission(), 'WooCommerce report caps alone must not grant export.' ); + $this->assertTrue( $this->controller->check_permission(), 'WooCommerce report caps grant export, as they grant the reports themselves.' ); remove_filter( 'user_has_cap', $grant_wc ); $grant_admin = static function ( $allcaps ) { diff --git a/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php index 33cb2f67c653..36e96ef7c5d6 100644 --- a/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php +++ b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php @@ -41,6 +41,24 @@ protected function login_as( $role ) { return $user_id; } + /** + * Grants a capability to a user and makes it visible to current_user_can(). + * + * A capability added after login stays invisible behind the cached user object, + * because wp_set_current_user() returns early when the ID is unchanged. Dropping + * to the logged-out user first forces that object to be rebuilt. + * + * @param int $user_id User to grant to. + * @param string $capability Capability to add. + * @return void + */ + protected function grant_capability_to( $user_id, $capability ) { + ( new \WP_User( $user_id ) )->add_cap( $capability ); + + wp_set_current_user( 0 ); + wp_set_current_user( $user_id ); + } + /** * Stands in for Stats' map_meta_cap, which the package under test does not boot. * From 403365396196268bb62503724c8c3882424cd958 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 15:04:43 +0800 Subject: [PATCH 06/10] Premium Analytics: rename the store-report capability helper --- .../Export/class-csv-export-controller.php | 4 +-- .../premium-analytics/src/capabilities.php | 16 ++++++----- .../src/dashboard-sections.php | 2 +- .../src/widget-availability.php | 27 +++++++++--------- .../tests/php/Capabilities_Test.php | 28 +++++++++---------- .../tests/php/Widget_Availability_Test.php | 22 +++++++-------- 6 files changed, 51 insertions(+), 48 deletions(-) diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php index 67c692829264..47a733a88e02 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php @@ -20,7 +20,7 @@ use WP_Error; use WP_REST_Request; use WP_REST_Response; -use function Automattic\Jetpack\PremiumAnalytics\current_user_can_read_analytics_prefix; +use function Automattic\Jetpack\PremiumAnalytics\current_user_can_view_store_reports; require_once __DIR__ . '/../../capabilities.php'; @@ -145,7 +145,7 @@ public function register_routes(): void { * @return bool True if user has permission. */ public function check_permission(): bool { - return current_user_can_read_analytics_prefix(); + return current_user_can_view_store_reports(); } /** diff --git a/projects/packages/premium-analytics/src/capabilities.php b/projects/packages/premium-analytics/src/capabilities.php index a59f5d28b7c0..d321c36ac66a 100644 --- a/projects/packages/premium-analytics/src/capabilities.php +++ b/projects/packages/premium-analytics/src/capabilities.php @@ -94,19 +94,21 @@ function current_user_can_view_analytics() { } /** - * Whether the current user may read data served by the proxy's `analytics` prefix. + * Whether the current user may read the store reports. * - * The store section and every widget backed by that prefix are hidden from - * readers who fail this, since all they could collect is 403s. The capability - * restated here is the one {@see \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller} - * enforces for the prefix; Capabilities_Test pins the two together so loosening - * the proxy can't silently leave these surfaces hidden. + * "Store reports" is everything the proxy serves from its `analytics` prefix — + * WooCommerce's own reporting data. The Store section and the widgets backed by + * that prefix are hidden from readers who fail this, since all they could + * collect is 403s. The capability restated here is the one + * {@see \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller} enforces + * for the prefix; Capabilities_Test pins the two together so loosening the proxy + * can't silently leave these surfaces hidden. * * @since $$next-version$$ * * @return bool */ -function current_user_can_read_analytics_prefix() { +function current_user_can_view_store_reports() { // The proxy accepts manage_options for every prefix, so administrators pass // even where WooCommerce never registered its capabilities. return current_user_can( 'manage_options' ) || current_user_can( 'view_woocommerce_reports' ); diff --git a/projects/packages/premium-analytics/src/dashboard-sections.php b/projects/packages/premium-analytics/src/dashboard-sections.php index cbc2b3181c31..8abd64b801c4 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -79,7 +79,7 @@ function is_woocommerce_dashboard_section_available() { * @return bool */ function is_woocommerce_dashboard_section_available_to_current_user() { - return is_woocommerce_dashboard_section_available() && current_user_can_read_analytics_prefix(); + return is_woocommerce_dashboard_section_available() && current_user_can_view_store_reports(); } /** diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index 64608716554c..a3514bb7f397 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -33,16 +33,17 @@ const WOOCOMMERCE_BOOKINGS_WIDGET_CATEGORIES = array( 'bookings' ); /** - * Widget categories served by the proxy's `analytics` prefix. + * Widget categories whose data counts as a store report. * * The membership rule is the data source, not the subject matter: every * category here reaches WPCOM through `analytics/reports/…`, which * {@see \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller} - * gates on `manage_options`. That covers the commerce categories, and - * `visitors`, whose two widgets read `sessions/…` from the same prefix - * without a plugin gate of their own. + * gates on `view_woocommerce_reports`. That covers the commerce categories, + * and `visitors`, whose two widgets read `sessions/…` from the same prefix + * without a plugin gate of their own — which is why the list is longer than + * the name suggests. */ -const ANALYTICS_PREFIX_WIDGET_CATEGORIES = array( 'store', 'orders', 'coupons', 'bookings', 'visitors' ); +const STORE_REPORT_WIDGET_CATEGORIES = array( 'store', 'orders', 'coupons', 'bookings', 'visitors' ); /** * Removes developer-only candidates in production. @@ -175,12 +176,12 @@ function filter_registrable_widget_types_by_plugin( $widget_candidates ) { * * @since $$next-version$$ * - * @param array $widget_candidates Manifest candidates, each with a `category`. - * @param bool $can_read_the_prefix Whether the reader may read the `analytics` prefix. - * @return array The candidates, minus that prefix's categories for readers who can't. + * @param array $widget_candidates Manifest candidates, each with a `category`. + * @param bool $can_view_store_reports Whether the reader may see the store reports. + * @return array The candidates, minus the store-report categories for readers who can't. */ -function remove_capability_gated_widget_types( $widget_candidates, $can_read_the_prefix ) { - if ( $can_read_the_prefix ) { +function remove_capability_gated_widget_types( $widget_candidates, $can_view_store_reports ) { + if ( $can_view_store_reports ) { return $widget_candidates; } @@ -188,7 +189,7 @@ function remove_capability_gated_widget_types( $widget_candidates, $can_read_the array_filter( $widget_candidates, static function ( $widget ) { - return ! in_array( $widget['category'] ?? '', ANALYTICS_PREFIX_WIDGET_CATEGORIES, true ); + return ! in_array( $widget['category'] ?? '', STORE_REPORT_WIDGET_CATEGORIES, true ); } ) ); @@ -204,12 +205,12 @@ static function ( $widget ) { * @since $$next-version$$ * * @param array $widget_candidates Manifest candidates. - * @return array The candidates, minus that prefix's categories for readers who can't see them. + * @return array The candidates, minus the store-report categories for readers who can't see them. */ function filter_registrable_widget_types_by_capability( $widget_candidates ) { return remove_capability_gated_widget_types( $widget_candidates, - current_user_can_read_analytics_prefix() + current_user_can_view_store_reports() ); } diff --git a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php index 9c26d97b27a3..17bfc2ecfe5e 100644 --- a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -19,12 +19,12 @@ * @covers ::Automattic\Jetpack\PremiumAnalytics\map_analytics_meta_caps * @covers ::Automattic\Jetpack\PremiumAnalytics\register_capabilities * @covers ::Automattic\Jetpack\PremiumAnalytics\current_user_can_view_analytics - * @covers ::Automattic\Jetpack\PremiumAnalytics\current_user_can_read_analytics_prefix + * @covers ::Automattic\Jetpack\PremiumAnalytics\current_user_can_view_store_reports */ #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\map_analytics_meta_caps' )] #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\register_capabilities' )] #[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\current_user_can_view_analytics' )] -#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\current_user_can_read_analytics_prefix' )] +#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\current_user_can_view_store_reports' )] class Capabilities_Test extends BaseTestCase { use Analytics_Capabilities_Trait; @@ -95,24 +95,24 @@ public function test_mapping_leaves_other_capabilities_alone() { } /** - * Surfaces backed by the `analytics` prefix stay administrator-only: reaching - * the dashboard through view_stats is not enough to read that prefix. + * The store reports are a separate grant: reaching the dashboard through + * view_stats says nothing about who may read WooCommerce's data. */ - public function test_view_stats_reader_cannot_read_the_analytics_prefix() { + public function test_view_stats_reader_cannot_view_store_reports() { $user_id = $this->login_as( 'editor' ); $this->grant_view_stats_to( $user_id ); $this->assertTrue( current_user_can_view_analytics() ); - $this->assertFalse( current_user_can_read_analytics_prefix() ); + $this->assertFalse( current_user_can_view_store_reports() ); } /** * Administrators keep them. */ - public function test_administrator_can_read_the_analytics_prefix() { + public function test_administrator_can_view_store_reports() { $this->login_as( 'administrator' ); - $this->assertTrue( current_user_can_read_analytics_prefix() ); + $this->assertTrue( current_user_can_view_store_reports() ); } /** @@ -127,7 +127,7 @@ public function test_administrator_can_read_the_analytics_prefix() { * reaches for the same user. Api_Proxy_Controller_Test pins the proxy's own * capability to `view_woocommerce_reports` in its endpoint matrix. */ - public function test_analytics_prefix_helper_matches_the_proxy_capability() { + public function test_store_report_helper_matches_the_proxy_capability() { $controller = new Api_Proxy_Controller(); $request = new WP_REST_Request( 'GET', '/jetpack-premium-analytics/v1/proxy/v2/analytics/reports/orders' ); $request->set_param( 'endpoint', 'analytics/reports/orders' ); @@ -137,7 +137,7 @@ public function test_analytics_prefix_helper_matches_the_proxy_capability() { $this->assertSame( $controller->check_data_permission( $request ), - current_user_can_read_analytics_prefix(), + current_user_can_view_store_reports(), 'A view_stats reader must be refused by the proxy and by the helper that hides its surfaces.' ); @@ -145,7 +145,7 @@ public function test_analytics_prefix_helper_matches_the_proxy_capability() { $this->assertSame( $controller->check_data_permission( $request ), - current_user_can_read_analytics_prefix(), + current_user_can_view_store_reports(), 'An administrator must be admitted by both.' ); @@ -155,10 +155,10 @@ public function test_analytics_prefix_helper_matches_the_proxy_capability() { $shop_manager = $this->login_as( 'subscriber' ); $this->grant_capability_to( $shop_manager, 'view_woocommerce_reports' ); - $this->assertTrue( current_user_can_read_analytics_prefix() ); + $this->assertTrue( current_user_can_view_store_reports() ); $this->assertSame( $controller->check_data_permission( $request ), - current_user_can_read_analytics_prefix(), + current_user_can_view_store_reports(), 'A WooCommerce report viewer must be admitted by both.' ); } @@ -171,7 +171,7 @@ public function test_woocommerce_report_viewer_is_not_a_dashboard_reader() { $shop_manager = $this->login_as( 'subscriber' ); $this->grant_capability_to( $shop_manager, 'view_woocommerce_reports' ); - $this->assertTrue( current_user_can_read_analytics_prefix() ); + $this->assertTrue( current_user_can_view_store_reports() ); $this->assertFalse( current_user_can_view_analytics() ); } } diff --git a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php index b6c94500cfbb..a0b81175e7ab 100644 --- a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -104,12 +104,12 @@ private function commerce_widget_candidates() { } /** - * Candidate set spanning every category the `analytics` prefix serves, plus - * one served by another prefix. + * Candidate set spanning every store-report category, plus one served from + * elsewhere. * * @return array[] List of widget candidates. */ - private function analytics_prefix_widget_candidates() { + private function store_report_widget_candidates() { return array_merge( $this->commerce_widget_candidates(), array( @@ -281,15 +281,15 @@ public function test_uncategorized_widgets_pass_through() { } /** - * Every category the `analytics` prefix serves — the commerce ones and - * `visitors` — is dropped for a reader who can't read that prefix, since all - * they could collect from those widgets is 403s. + * Every store-report category — the commerce ones and `visitors` — is dropped + * for a reader without that access, since all they could collect from those + * widgets is 403s. */ - public function test_analytics_prefix_widgets_removed_from_a_reader_without_access() { + public function test_store_report_widgets_removed_from_a_reader_without_access() { $this->assertSame( array( 'jpa/traffic-chart' ), array_column( - remove_capability_gated_widget_types( $this->analytics_prefix_widget_candidates(), false ), + remove_capability_gated_widget_types( $this->store_report_widget_candidates(), false ), 'name' ), 'Only the category served by another prefix survives.' @@ -299,10 +299,10 @@ public function test_analytics_prefix_widgets_removed_from_a_reader_without_acce /** * Administrators keep every category. */ - public function test_analytics_prefix_widgets_kept_for_a_user_with_access() { + public function test_store_report_widgets_kept_for_a_user_with_access() { $this->assertSame( - $this->analytics_prefix_widget_candidates(), - remove_capability_gated_widget_types( $this->analytics_prefix_widget_candidates(), true ), + $this->store_report_widget_candidates(), + remove_capability_gated_widget_types( $this->store_report_widget_candidates(), true ), 'With prefix access no candidate is dropped.' ); } From 763d07192ffd524cf31a6b40badd1fd975feffc2 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 15:16:27 +0800 Subject: [PATCH 07/10] Premium Analytics: name the dashboard capability jetpack_view_analytics --- .phpcs.xml.dist | 2 +- projects/packages/premium-analytics/src/capabilities.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index a7184c901464..2c881a7e42d0 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -36,7 +36,7 @@ - + diff --git a/projects/packages/premium-analytics/src/capabilities.php b/projects/packages/premium-analytics/src/capabilities.php index d321c36ac66a..9fb03e9a2e6a 100644 --- a/projects/packages/premium-analytics/src/capabilities.php +++ b/projects/packages/premium-analytics/src/capabilities.php @@ -16,7 +16,7 @@ /** * Meta capability for reading the dashboard. */ -const VIEW_ANALYTICS_CAPABILITY = 'view_jetpack_analytics'; +const VIEW_ANALYTICS_CAPABILITY = 'jetpack_view_analytics'; /** * Hooks the dashboard's meta capability mapping. From cffc00c1ffe3e173de0c3c4d142e758056a96c53 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 15:25:51 +0800 Subject: [PATCH 08/10] Premium Analytics: trim the capability layer's doc comments --- .phpcs.xml.dist | 1 - .../src/REST/class-api-proxy-controller.php | 6 ++--- .../Export/class-csv-export-controller.php | 6 ++--- .../premium-analytics/src/capabilities.php | 26 ++++++------------- .../premium-analytics/src/class-analytics.php | 5 +--- .../src/dashboard-sections.php | 5 ++-- .../src/widget-availability.php | 10 +++---- .../tests/php/Capabilities_Test.php | 11 +++----- 8 files changed, 23 insertions(+), 47 deletions(-) diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 2c881a7e42d0..f0ca914eef8c 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -38,7 +38,6 @@ - diff --git a/projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php b/projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php index 13e962f1a2f6..d915092c4575 100644 --- a/projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php +++ b/projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php @@ -119,10 +119,8 @@ class Api_Proxy_Controller extends WP_REST_Controller { * @var array> */ private const PREFIX_CONFIG = array( - // Store reports, gated as WooCommerce gates its own Analytics screens, which - // shop managers can read. woocommerce-analytics made the same move away from - // manage_options (WOOA7S-551); requiring it here would lock those users out of - // reports they already have elsewhere. + // Gated like WooCommerce's own Analytics screens, which shop managers can read; + // woocommerce-analytics made the same move away from manage_options (WOOA7S-551). 'analytics' => array( 'capability' => 'view_woocommerce_reports' ), 'stats' => array( 'capability' => 'view_stats', diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php index 47a733a88e02..c708b38864c5 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php @@ -138,9 +138,9 @@ public function register_routes(): void { /** * Check if user has permission to export reports. * - * Every report exported here is fetched through the proxy's `analytics` prefix, so - * this defers to the same check rather than restating a capability: advertising - * access the async fetch cannot honor would schedule a job that then fails. + * Must match the capability the analytics proxy enforces (the `analytics` prefix + * in Api_Proxy_Controller); otherwise the route would advertise access the async + * data fetch cannot honor, scheduling a job that then fails. * * @return bool True if user has permission. */ diff --git a/projects/packages/premium-analytics/src/capabilities.php b/projects/packages/premium-analytics/src/capabilities.php index 9fb03e9a2e6a..0228c6fabb65 100644 --- a/projects/packages/premium-analytics/src/capabilities.php +++ b/projects/packages/premium-analytics/src/capabilities.php @@ -22,12 +22,8 @@ * Hooks the dashboard's meta capability mapping. * * Deliberately not called at file scope: this file is included while the - * Analytics class is autoloaded, which happens in contexts where WordPress - * itself isn't loaded, and add_filter() would be undefined there. Callers hook - * it from their own WordPress-aware entry points instead — both - * Analytics::init() paths, and Dashboard_Support_Routes, which WPCOM Simple - * boots standalone without ever reaching Analytics::init(). Idempotent, so - * overlapping callers are free to call it. + * Analytics class is autoloaded, which happens in contexts where WordPress — + * and add_filter() — isn't loaded. WordPress-aware entry points call it instead. * * @since $$next-version$$ * @@ -40,11 +36,8 @@ function register_capabilities() { /** * Unhooks the mapping registered by register_capabilities(). * - * The counterpart exists so a caller — a test tearing down between cases, or a - * host unwinding the package — can drop this one filter by name instead of - * reaching for remove_all_filters( 'map_meta_cap' ), which would also take out - * whatever else is hooked there (the Stats package maps `view_stats` on that - * same filter). + * Test tear-down needs this to drop the one filter: remove_all_filters( + * 'map_meta_cap' ) would also take out Stats' own `view_stats` mapping. * * @since $$next-version$$ * @@ -97,19 +90,16 @@ function current_user_can_view_analytics() { * Whether the current user may read the store reports. * * "Store reports" is everything the proxy serves from its `analytics` prefix — - * WooCommerce's own reporting data. The Store section and the widgets backed by - * that prefix are hidden from readers who fail this, since all they could - * collect is 403s. The capability restated here is the one + * WooCommerce's own reporting data. Mirrors the capability * {@see \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller} enforces - * for the prefix; Capabilities_Test pins the two together so loosening the proxy - * can't silently leave these surfaces hidden. + * there (Capabilities_Test pins the two together); surfaces backed by the prefix + * are hidden from readers who fail it, since all they could collect is 403s. * * @since $$next-version$$ * * @return bool */ function current_user_can_view_store_reports() { - // The proxy accepts manage_options for every prefix, so administrators pass - // even where WooCommerce never registered its capabilities. + // The proxy accepts manage_options for every prefix. return current_user_can( 'manage_options' ) || current_user_can( 'view_woocommerce_reports' ); } diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index 6d790370c58d..cfaab41285c9 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -14,8 +14,6 @@ use Automattic\Jetpack\PremiumAnalytics\Sync\Sync_Status_Tracker; use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills; -// Defines the capability the admin menu is registered with. Declarations only — -// this runs on autoload, where WordPress may not be loaded yet. require_once __DIR__ . '/capabilities.php'; /** @@ -134,8 +132,7 @@ private static function apply_options( $options ) { * @return void */ private static function boot_shared_services() { - // Who may read the dashboard. Hooked on every platform, and ahead of the - // hooks that check it (admin_menu, rest_api_init). + // Must be hooked before admin_menu and rest_api_init check the capability. register_capabilities(); // Emit WooCommerce store events into the Woo pipeline (ClickHouse + proxy). diff --git a/projects/packages/premium-analytics/src/dashboard-sections.php b/projects/packages/premium-analytics/src/dashboard-sections.php index 8abd64b801c4..7789ee586ec1 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -70,9 +70,8 @@ function is_woocommerce_dashboard_section_available() { /** * Whether the current user should be shown the WooCommerce dashboard section. * - * Kept apart from is_woocommerce_dashboard_section_available(), which answers - * "is WooCommerce here" and is also the signal the widget registry reads. This - * one adds "and may this reader see store data". + * The sibling is_woocommerce_dashboard_section_available() answers "is + * WooCommerce here"; this adds "and may this reader see store data". * * @since $$next-version$$ * diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index a3514bb7f397..dfe7dad7da0b 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -36,12 +36,10 @@ * Widget categories whose data counts as a store report. * * The membership rule is the data source, not the subject matter: every - * category here reaches WPCOM through `analytics/reports/…`, which - * {@see \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller} - * gates on `view_woocommerce_reports`. That covers the commerce categories, - * and `visitors`, whose two widgets read `sessions/…` from the same prefix - * without a plugin gate of their own — which is why the list is longer than - * the name suggests. + * category here reaches WPCOM through the proxy's `analytics` prefix, which + * {@see \Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller} gates + * on `view_woocommerce_reports`. That is why `visitors` is in the list: its + * widgets read `sessions/…` from the same prefix. */ const STORE_REPORT_WIDGET_CATEGORIES = array( 'store', 'orders', 'coupons', 'bookings', 'visitors' ); diff --git a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php index 17bfc2ecfe5e..b370693fda12 100644 --- a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -116,16 +116,11 @@ public function test_administrator_can_view_store_reports() { } /** - * The helper restates the capability the proxy enforces for the `analytics` - * prefix, so the two are pinned to each other rather than to a literal: a - * proxy that loosens without the helper following would leave these surfaces - * hidden from readers whose data they could now serve, and the reverse would - * offer widgets that answer 403. - * + * Pins the helper to the capability the proxy enforces for the `analytics` + * prefix, so the two can't drift apart and leave widgets that answer 403. * Asserted through check_data_permission() rather than by reading * PREFIX_CONFIG, so what's compared is the decision each side actually - * reaches for the same user. Api_Proxy_Controller_Test pins the proxy's own - * capability to `view_woocommerce_reports` in its endpoint matrix. + * reaches for the same user. */ public function test_store_report_helper_matches_the_proxy_capability() { $controller = new Api_Proxy_Controller(); From bf87793b16b2fec99fdd3738361dda4a301eef56 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 29 Jul 2026 15:50:03 +0800 Subject: [PATCH 09/10] Premium Analytics: make the capability layer an autoloaded class --- .../src/REST/class-notices-controller.php | 6 +- .../Export/class-csv-export-controller.php | 6 +- .../premium-analytics/src/capabilities.php | 105 ----------------- .../premium-analytics/src/class-analytics.php | 6 +- .../src/class-capabilities.php | 108 ++++++++++++++++++ .../src/class-dashboard-support-routes.php | 3 +- .../src/dashboard-layout.php | 3 +- .../src/dashboard-sections.php | 5 +- .../src/widget-availability.php | 3 +- .../premium-analytics/src/widget-modules.php | 3 +- .../tests/php/Analytics_Test.php | 8 +- .../tests/php/Capabilities_Test.php | 43 +++---- .../tests/php/Dashboard_Layout_Test.php | 2 +- .../tests/php/Dashboard_Section_Test.php | 2 +- .../php/Dashboard_Support_Routes_Test.php | 4 +- .../php/REST/Notices_Controller_Test.php | 5 +- .../tests/php/Widget_Availability_Test.php | 41 +++++++ .../tests/php/Widget_Modules_Test.php | 2 +- .../traits/trait-analytics-capabilities.php | 2 +- 19 files changed, 192 insertions(+), 165 deletions(-) delete mode 100644 projects/packages/premium-analytics/src/capabilities.php create mode 100644 projects/packages/premium-analytics/src/class-capabilities.php diff --git a/projects/packages/premium-analytics/src/REST/class-notices-controller.php b/projects/packages/premium-analytics/src/REST/class-notices-controller.php index f573a4a156eb..c0acf7b5794e 100644 --- a/projects/packages/premium-analytics/src/REST/class-notices-controller.php +++ b/projects/packages/premium-analytics/src/REST/class-notices-controller.php @@ -7,12 +7,10 @@ namespace Automattic\Jetpack\PremiumAnalytics\REST; +use Automattic\Jetpack\PremiumAnalytics\Capabilities; use Automattic\Jetpack\PremiumAnalytics\Notices; use WP_REST_Request; use WP_REST_Server; -use function Automattic\Jetpack\PremiumAnalytics\current_user_can_view_analytics; - -require_once __DIR__ . '/../capabilities.php'; /** * Exposes `jetpack-premium-analytics/v1/notices` (GET + POST). @@ -102,7 +100,7 @@ public function register_routes(): void { * @return bool */ public function check_permission(): bool { - return current_user_can_view_analytics(); + return Capabilities::current_user_can_view_analytics(); } /** diff --git a/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php b/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php index c708b38864c5..c66651277852 100644 --- a/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php +++ b/projects/packages/premium-analytics/src/Reports/Export/class-csv-export-controller.php @@ -13,6 +13,7 @@ defined( 'ABSPATH' ) || exit; +use Automattic\Jetpack\PremiumAnalytics\Capabilities; use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Logging\Logger_Interface; use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Logger_Trait; use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Utilities; @@ -20,9 +21,6 @@ use WP_Error; use WP_REST_Request; use WP_REST_Response; -use function Automattic\Jetpack\PremiumAnalytics\current_user_can_view_store_reports; - -require_once __DIR__ . '/../../capabilities.php'; /** * CSV Export Controller class. @@ -145,7 +143,7 @@ public function register_routes(): void { * @return bool True if user has permission. */ public function check_permission(): bool { - return current_user_can_view_store_reports(); + return Capabilities::current_user_can_view_store_reports(); } /** diff --git a/projects/packages/premium-analytics/src/capabilities.php b/projects/packages/premium-analytics/src/capabilities.php deleted file mode 100644 index 0228c6fabb65..000000000000 --- a/projects/packages/premium-analytics/src/capabilities.php +++ /dev/null @@ -1,105 +0,0 @@ - \WP_REST_Server::READABLE, 'callback' => __NAMESPACE__ . '\\get_dashboard_default_layout_response', - 'permission_callback' => __NAMESPACE__ . '\\current_user_can_view_analytics', + 'permission_callback' => array( Capabilities::class, 'current_user_can_view_analytics' ), 'args' => array( 'name' => array( 'description' => __( 'Dashboard identifier as produced by the build pipeline.', 'jetpack-premium-analytics-pkg' ), diff --git a/projects/packages/premium-analytics/src/dashboard-sections.php b/projects/packages/premium-analytics/src/dashboard-sections.php index 7789ee586ec1..6bbdc2933bab 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -9,7 +9,6 @@ require_once __DIR__ . '/dashboard-layout.php'; require_once __DIR__ . '/dashboard-grammar.php'; -require_once __DIR__ . '/capabilities.php'; require_once __DIR__ . '/class-dashboard-section.php'; require_once __DIR__ . '/class-dashboard-section-registry.php'; @@ -78,7 +77,7 @@ function is_woocommerce_dashboard_section_available() { * @return bool */ function is_woocommerce_dashboard_section_available_to_current_user() { - return is_woocommerce_dashboard_section_available() && current_user_can_view_store_reports(); + return is_woocommerce_dashboard_section_available() && Capabilities::current_user_can_view_store_reports(); } /** @@ -154,7 +153,7 @@ function bootstrap_dashboard_sections() { * @return bool */ function check_dashboard_sections_permission() { - return current_user_can_view_analytics(); + return Capabilities::current_user_can_view_analytics(); } /** diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php index dfe7dad7da0b..56b838151cdd 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -17,7 +17,6 @@ require_once __DIR__ . '/widget-types.php'; require_once __DIR__ . '/widget-type-support.php'; -require_once __DIR__ . '/capabilities.php'; /** * Widget categories that are only meaningful with WooCommerce active. @@ -208,7 +207,7 @@ static function ( $widget ) { function filter_registrable_widget_types_by_capability( $widget_candidates ) { return remove_capability_gated_widget_types( $widget_candidates, - current_user_can_view_store_reports() + Capabilities::current_user_can_view_store_reports() ); } diff --git a/projects/packages/premium-analytics/src/widget-modules.php b/projects/packages/premium-analytics/src/widget-modules.php index 9a4f5a0bda3f..cf02949fb716 100644 --- a/projects/packages/premium-analytics/src/widget-modules.php +++ b/projects/packages/premium-analytics/src/widget-modules.php @@ -13,7 +13,6 @@ namespace Automattic\Jetpack\PremiumAnalytics; require_once __DIR__ . '/rest-namespace.php'; -require_once __DIR__ . '/capabilities.php'; /** * Register the `/wpcom/v2/widget-modules` REST route. @@ -27,7 +26,7 @@ function register_widget_modules_rest_route() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => __NAMESPACE__ . '\\get_widget_modules_response', - 'permission_callback' => __NAMESPACE__ . '\\current_user_can_view_analytics', + 'permission_callback' => array( Capabilities::class, 'current_user_can_view_analytics' ), ) ); } diff --git a/projects/packages/premium-analytics/tests/php/Analytics_Test.php b/projects/packages/premium-analytics/tests/php/Analytics_Test.php index 72e5827c8d33..805e25ed9d6d 100644 --- a/projects/packages/premium-analytics/tests/php/Analytics_Test.php +++ b/projects/packages/premium-analytics/tests/php/Analytics_Test.php @@ -49,7 +49,7 @@ protected function tearDown(): void { remove_all_filters( 'jetpack_admin_js_script_data' ); remove_all_filters( 'rest_post_dispatch' ); remove_all_filters( 'jetpack_stats_transient_cleanup_prefixes' ); - unregister_capabilities(); + Capabilities::unregister(); $this->reset_analytics_init_state(); parent::tearDown(); } @@ -114,7 +114,7 @@ public function test_init_maps_the_dashboard_capability( $wpcom_simple ) { } $this->assertNotFalse( - has_filter( 'map_meta_cap', __NAMESPACE__ . '\\map_analytics_meta_caps' ) + has_filter( 'map_meta_cap', array( Capabilities::class, 'map_meta_caps' ) ) ); } @@ -235,7 +235,7 @@ public function test_is_dashboard_request_false_on_front_end() { public function test_register_admin_menu_uses_the_dashboard_capability() { $menu_item = $this->register_admin_menu_without_build(); - $this->assertSame( VIEW_ANALYTICS_CAPABILITY, $menu_item[1] ?? null ); + $this->assertSame( Capabilities::VIEW_ANALYTICS, $menu_item[1] ?? null ); } /** @@ -401,7 +401,7 @@ private function register_admin_menu_without_build() { // Hooked by boot_shared_services() in production; add_menu_page() resolves the // dashboard capability, and an unmapped one would skip the page's render hook. - register_capabilities(); + Capabilities::register(); add_filter( 'user_has_cap', array( $this, 'grant_manage_options' ) ); $this->capture_doing_it_wrong(); diff --git a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php index b370693fda12..19d93e611f97 100644 --- a/projects/packages/premium-analytics/tests/php/Capabilities_Test.php +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -8,23 +8,16 @@ namespace Automattic\Jetpack\PremiumAnalytics; use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller; -use PHPUnit\Framework\Attributes\CoversFunction; +use PHPUnit\Framework\Attributes\CoversClass; use WorDBless\BaseTestCase; use WP_REST_Request; -require_once __DIR__ . '/../../src/capabilities.php'; require_once __DIR__ . '/traits/trait-analytics-capabilities.php'; /** - * @covers ::Automattic\Jetpack\PremiumAnalytics\map_analytics_meta_caps - * @covers ::Automattic\Jetpack\PremiumAnalytics\register_capabilities - * @covers ::Automattic\Jetpack\PremiumAnalytics\current_user_can_view_analytics - * @covers ::Automattic\Jetpack\PremiumAnalytics\current_user_can_view_store_reports + * @covers \Automattic\Jetpack\PremiumAnalytics\Capabilities */ -#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\map_analytics_meta_caps' )] -#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\register_capabilities' )] -#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\current_user_can_view_analytics' )] -#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\current_user_can_view_store_reports' )] +#[CoversClass( Capabilities::class )] class Capabilities_Test extends BaseTestCase { use Analytics_Capabilities_Trait; @@ -33,7 +26,7 @@ class Capabilities_Test extends BaseTestCase { * Hook the mapping under test, the way a WordPress-aware entry point would. */ public function set_up() { - register_capabilities(); + Capabilities::register(); } /** @@ -53,7 +46,7 @@ public function tear_down() { public function test_administrator_can_view_analytics_without_the_stats_mapping() { $this->login_as( 'administrator' ); - $this->assertTrue( current_user_can_view_analytics() ); + $this->assertTrue( Capabilities::current_user_can_view_analytics() ); } /** @@ -63,7 +56,7 @@ public function test_editor_granted_view_stats_can_view_analytics() { $user_id = $this->login_as( 'editor' ); $this->grant_view_stats_to( $user_id ); - $this->assertTrue( current_user_can_view_analytics() ); + $this->assertTrue( Capabilities::current_user_can_view_analytics() ); } /** @@ -72,7 +65,7 @@ public function test_editor_granted_view_stats_can_view_analytics() { public function test_plain_editor_cannot_view_analytics() { $this->login_as( 'editor' ); - $this->assertFalse( current_user_can_view_analytics() ); + $this->assertFalse( Capabilities::current_user_can_view_analytics() ); } /** @@ -81,7 +74,7 @@ public function test_plain_editor_cannot_view_analytics() { public function test_logged_out_user_cannot_view_analytics() { wp_set_current_user( 0 ); - $this->assertFalse( current_user_can_view_analytics() ); + $this->assertFalse( Capabilities::current_user_can_view_analytics() ); } /** @@ -90,7 +83,7 @@ public function test_logged_out_user_cannot_view_analytics() { public function test_mapping_leaves_other_capabilities_alone() { $this->assertSame( array( 'edit_posts' ), - map_analytics_meta_caps( array( 'edit_posts' ), 'edit_posts', 1 ) + Capabilities::map_meta_caps( array( 'edit_posts' ), 'edit_posts', 1 ) ); } @@ -102,8 +95,8 @@ public function test_view_stats_reader_cannot_view_store_reports() { $user_id = $this->login_as( 'editor' ); $this->grant_view_stats_to( $user_id ); - $this->assertTrue( current_user_can_view_analytics() ); - $this->assertFalse( current_user_can_view_store_reports() ); + $this->assertTrue( Capabilities::current_user_can_view_analytics() ); + $this->assertFalse( Capabilities::current_user_can_view_store_reports() ); } /** @@ -112,7 +105,7 @@ public function test_view_stats_reader_cannot_view_store_reports() { public function test_administrator_can_view_store_reports() { $this->login_as( 'administrator' ); - $this->assertTrue( current_user_can_view_store_reports() ); + $this->assertTrue( Capabilities::current_user_can_view_store_reports() ); } /** @@ -132,7 +125,7 @@ public function test_store_report_helper_matches_the_proxy_capability() { $this->assertSame( $controller->check_data_permission( $request ), - current_user_can_view_store_reports(), + Capabilities::current_user_can_view_store_reports(), 'A view_stats reader must be refused by the proxy and by the helper that hides its surfaces.' ); @@ -140,7 +133,7 @@ public function test_store_report_helper_matches_the_proxy_capability() { $this->assertSame( $controller->check_data_permission( $request ), - current_user_can_view_store_reports(), + Capabilities::current_user_can_view_store_reports(), 'An administrator must be admitted by both.' ); @@ -150,10 +143,10 @@ public function test_store_report_helper_matches_the_proxy_capability() { $shop_manager = $this->login_as( 'subscriber' ); $this->grant_capability_to( $shop_manager, 'view_woocommerce_reports' ); - $this->assertTrue( current_user_can_view_store_reports() ); + $this->assertTrue( Capabilities::current_user_can_view_store_reports() ); $this->assertSame( $controller->check_data_permission( $request ), - current_user_can_view_store_reports(), + Capabilities::current_user_can_view_store_reports(), 'A WooCommerce report viewer must be admitted by both.' ); } @@ -166,7 +159,7 @@ public function test_woocommerce_report_viewer_is_not_a_dashboard_reader() { $shop_manager = $this->login_as( 'subscriber' ); $this->grant_capability_to( $shop_manager, 'view_woocommerce_reports' ); - $this->assertTrue( current_user_can_view_store_reports() ); - $this->assertFalse( current_user_can_view_analytics() ); + $this->assertTrue( Capabilities::current_user_can_view_store_reports() ); + $this->assertFalse( Capabilities::current_user_can_view_analytics() ); } } diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php index 321850ea3dbb..87a713b62e8c 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php @@ -63,7 +63,7 @@ public function test_default_layout_route_is_gated_on_the_dashboard_capability() $routes = rest_get_server()->get_routes(); $this->assertSame( - __NAMESPACE__ . '\\current_user_can_view_analytics', + array( Capabilities::class, 'current_user_can_view_analytics' ), $routes[ self::ROUTE ][0]['permission_callback'] ); } diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php index e934685a7c08..b7ac74983637 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Section_Test.php @@ -47,7 +47,7 @@ public function set_up() { // Hooked by the package's entry points in production; the routes under test // are gated on the capability it maps. - register_capabilities(); + Capabilities::register(); } /** diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php index d1db91fe40f9..5f063c8f30d3 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php @@ -23,7 +23,7 @@ protected function tearDown(): void { $wp_rest_server = null; remove_all_actions( 'rest_api_init' ); remove_all_actions( 'init' ); - unregister_capabilities(); + Capabilities::unregister(); parent::tearDown(); } @@ -82,7 +82,7 @@ public function test_boot_routes_maps_the_dashboard_capability() { do_action( 'rest_api_init' ); $this->assertNotFalse( - has_filter( 'map_meta_cap', __NAMESPACE__ . '\\map_analytics_meta_caps' ) + has_filter( 'map_meta_cap', array( Capabilities::class, 'map_meta_caps' ) ) ); } } diff --git a/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php b/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php index ad2aaffca4d4..70badfabfbe5 100644 --- a/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php +++ b/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\PremiumAnalytics\REST; +use Automattic\Jetpack\PremiumAnalytics\Capabilities; use PHPUnit\Framework\Attributes\CoversClass; use WorDBless\BaseTestCase; use WP_REST_Server; @@ -35,7 +36,7 @@ public function set_up() { // Hooked alongside this controller by Analytics::init() in production; the // route's permission check reads the capability it maps. - \Automattic\Jetpack\PremiumAnalytics\register_capabilities(); + Capabilities::register(); global $wp_rest_server; $wp_rest_server = new WP_REST_Server(); @@ -47,7 +48,7 @@ public function set_up() { * Drop the capability mapping this test hooked. */ public function tear_down() { - \Automattic\Jetpack\PremiumAnalytics\unregister_capabilities(); + Capabilities::unregister(); parent::tear_down(); } diff --git a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php index a0b81175e7ab..ca5f0bbee8e1 100644 --- a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -307,6 +307,47 @@ public function test_store_report_widgets_kept_for_a_user_with_access() { ); } + /** + * The registry-time callback reads the current user, so the same manifest + * yields different types depending on who is asking. + */ + public function test_registry_callback_follows_the_current_user() { + $reader = wp_insert_user( + array( + 'user_login' => 'jpa_widget_reader', + 'user_pass' => 'password', + 'role' => 'editor', + ) + ); + wp_set_current_user( $reader ); + + $this->assertSame( + array( 'jpa/traffic-chart' ), + array_column( + filter_registrable_widget_types_by_capability( $this->store_report_widget_candidates() ), + 'name' + ), + 'An editor cannot read the store reports, so their categories are dropped.' + ); + + $admin = wp_insert_user( + array( + 'user_login' => 'jpa_widget_admin', + 'user_pass' => 'password', + 'role' => 'administrator', + ) + ); + wp_set_current_user( $admin ); + + $this->assertSame( + $this->store_report_widget_candidates(), + filter_registrable_widget_types_by_capability( $this->store_report_widget_candidates() ), + 'An administrator keeps every category.' + ); + + wp_set_current_user( 0 ); + } + /** * Candidates without a category are never capability-gated. */ diff --git a/projects/packages/premium-analytics/tests/php/Widget_Modules_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Modules_Test.php index d0791fbb7b43..3c39af7a1aae 100644 --- a/projects/packages/premium-analytics/tests/php/Widget_Modules_Test.php +++ b/projects/packages/premium-analytics/tests/php/Widget_Modules_Test.php @@ -61,7 +61,7 @@ public function test_widget_modules_route_is_gated_on_the_dashboard_capability() $routes = rest_get_server()->get_routes(); $this->assertSame( - __NAMESPACE__ . '\\current_user_can_view_analytics', + array( Capabilities::class, 'current_user_can_view_analytics' ), $routes[ self::ROUTE ][0]['permission_callback'] ); } diff --git a/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php index 36e96ef7c5d6..d2652d855586 100644 --- a/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php +++ b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php @@ -84,7 +84,7 @@ protected function grant_view_stats_to( $granted_user_id ) { * @return void */ protected function reset_analytics_capabilities() { - unregister_capabilities(); + Capabilities::unregister(); if ( null !== $this->granted_view_stats_filter ) { remove_filter( 'map_meta_cap', $this->granted_view_stats_filter, 10 ); From 66833cd81967b801f6f5fdb7b17b4206c75cb3bb Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Fri, 31 Jul 2026 14:29:27 +0800 Subject: [PATCH 10/10] Premium Analytics: refuse the store tab's default layout to readers without store access --- .../src/dashboard-layout.php | 22 +++- .../tests/php/Dashboard_Layout_Test.php | 102 +++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/projects/packages/premium-analytics/src/dashboard-layout.php b/projects/packages/premium-analytics/src/dashboard-layout.php index cd900c5ca3c9..49d5150c57c2 100644 --- a/projects/packages/premium-analytics/src/dashboard-layout.php +++ b/projects/packages/premium-analytics/src/dashboard-layout.php @@ -75,11 +75,29 @@ function get_dashboard_default_layout_for( $dashboard_name ) { /** * REST callback returning the default layout for the requested dashboard. * + * The route admits every dashboard reader, but its name also resolves to a + * single tab, so the store tab is refused here the way the section route + * refuses it through {@see Dashboard_Section::is_available()}. The check keys + * on the resolved tab rather than the string because two spellings arrive: the + * bare `store` alias in the URL, and `?name=woocommerce/store`, which WordPress + * reads ahead of the URL capture. + * * @param \WP_REST_Request $request REST request carrying the dashboard name. - * @return \WP_REST_Response Response wrapping the default layout array. + * @return \WP_REST_Response|\WP_Error Response wrapping the default layout array. */ function get_dashboard_default_layout_response( $request ) { - return rest_ensure_response( get_dashboard_default_layout_for( $request['name'] ) ); + $dashboard_name = $request['name']; + + if ( DASHBOARD_STORE_SECTION_ID === get_dashboard_default_section_id_for( $dashboard_name ) + && ! Capabilities::current_user_can_view_store_reports() ) { + return new \WP_Error( + 'dashboard_section_unavailable', + __( 'Dashboard section is not available.', 'jetpack-premium-analytics-pkg' ), + array( 'status' => 404 ) + ); + } + + return rest_ensure_response( get_dashboard_default_layout_for( $dashboard_name ) ); } /** diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php index 87a713b62e8c..a39f642a27f8 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php @@ -8,27 +8,65 @@ namespace Automattic\Jetpack\PremiumAnalytics; use Automattic\Jetpack\Constants; -use PHPUnit\Framework\TestCase; +use WorDBless\BaseTestCase; +use WP_REST_Request; use WP_REST_Server; require_once __DIR__ . '/../../src/dashboard-layout.php'; +require_once __DIR__ . '/traits/trait-analytics-capabilities.php'; /** * Tests for Premium Analytics dashboard layout defaults. */ -class Dashboard_Layout_Test extends TestCase { +class Dashboard_Layout_Test extends BaseTestCase { + + use Analytics_Capabilities_Trait; const ROUTE = '/wpcom/v2/dashboards/(?P[a-z][a-z0-9-]*(?:_[a-z0-9-]+)*)/default-layout'; const LEGACY_ROUTE = '/jetpack/v4/dashboards/(?P[a-z][a-z0-9-]*(?:_[a-z0-9-]+)*)/default-layout'; /** - * Reset REST globals and constants between tests. + * Reset REST globals, capabilities, and constants between tests. */ - protected function tearDown(): void { + public function tear_down() { global $wp_rest_server; $wp_rest_server = null; + $this->reset_analytics_capabilities(); + wp_set_current_user( 0 ); Constants::clear_constants(); - parent::tearDown(); + parent::tear_down(); + } + + /** + * Boots the route with the capability mapping its permission callback needs. + * + * @return void + */ + private function register_route_with_capabilities() { + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + + register_dashboard_default_layout_route(); + Capabilities::register(); + } + + /** + * Requests a dashboard's default layout and returns the widget types served. + * + * @param string $url_name Dashboard name in the URL path. + * @param string $query_name Optional dashboard name sent as a query param. + * @return array{0:int,1:string[]} Response status and widget types. + */ + private function request_default_layout( $url_name, $query_name = null ) { + $request = new WP_REST_Request( 'GET', '/wpcom/v2/dashboards/' . $url_name . '/default-layout' ); + + if ( null !== $query_name ) { + $request->set_query_params( array( 'name' => $query_name ) ); + } + + $response = rest_get_server()->dispatch( $request ); + + return array( $response->get_status(), array_column( (array) $response->get_data(), 'type' ) ); } /** @@ -68,6 +106,60 @@ public function test_default_layout_route_is_gated_on_the_dashboard_capability() ); } + /** + * `store` is one of the tab aliases the name resolves through and matches the + * route's own name pattern, so the store tab is reachable straight from the + * URL — a reader admitted by view_stats alone must not be served its layout. + */ + public function test_default_layout_route_refuses_the_store_tab_for_a_view_stats_reader() { + $this->register_route_with_capabilities(); + $this->grant_view_stats_to( $this->login_as( 'editor' ) ); + + list( $status ) = $this->request_default_layout( DASHBOARD_STORE_SECTION_ID ); + + $this->assertSame( 404, $status ); + } + + /** + * WordPress reads query params ahead of the URL capture, so `?name=` reaches + * the callback with a value the route pattern would never have matched. + */ + public function test_default_layout_route_refuses_a_name_shadowed_store_tab() { + $this->register_route_with_capabilities(); + $this->grant_view_stats_to( $this->login_as( 'editor' ) ); + + list( $status ) = $this->request_default_layout( DASHBOARD_NAME, 'woocommerce/store' ); + + $this->assertSame( 404, $status ); + } + + /** + * The refusal is the reader's, not the tab's: an administrator still gets it. + */ + public function test_default_layout_route_serves_the_store_tab_to_an_administrator() { + $this->register_route_with_capabilities(); + $this->login_as( 'administrator' ); + + list( $status, $types ) = $this->request_default_layout( DASHBOARD_STORE_SECTION_ID ); + + $this->assertSame( 200, $status ); + $this->assertContains( 'jpa/store-performance', $types ); + } + + /** + * Only the store tab is gated: the reader's own tabs are served as before. + */ + public function test_default_layout_route_serves_the_traffic_tab_to_a_view_stats_reader() { + $this->register_route_with_capabilities(); + $this->grant_view_stats_to( $this->login_as( 'editor' ) ); + + list( $status, $types ) = $this->request_default_layout( DASHBOARD_NAME ); + + $this->assertSame( 200, $status ); + $this->assertContains( 'jpa/traffic-chart', $types ); + $this->assertNotContains( 'jpa/store-performance', $types ); + } + /** * Non-Premium-Analytics dashboards are left untouched. */