diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 4ceff1da3cae..f0ca914eef8c 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -36,7 +36,9 @@ + + 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 new file mode 100644 index 000000000000..dfb08e4005ba --- /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, 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..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,7 +119,9 @@ class Api_Proxy_Controller extends WP_REST_Controller { * @var array> */ private const PREFIX_CONFIG = array( - 'analytics' => array( 'capability' => 'manage_options' ), + // 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', 'writes' => array( 'stats/referrers/spam/' ), 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..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,6 +7,7 @@ namespace Automattic\Jetpack\PremiumAnalytics\REST; +use Automattic\Jetpack\PremiumAnalytics\Capabilities; use Automattic\Jetpack\PremiumAnalytics\Notices; use WP_REST_Request; use WP_REST_Server; @@ -99,7 +100,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 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 139e13d07f11..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; @@ -135,14 +136,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. + * 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. */ public function check_permission(): bool { - return current_user_can( 'manage_options' ); + return Capabilities::current_user_can_view_store_reports(); } /** diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index 965b97a87c52..99944c2872b6 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -130,6 +130,9 @@ private static function apply_options( $options ) { * @return void */ private static function boot_shared_services() { + // Must be hooked before admin_menu and rest_api_init check the capability. + Capabilities::register(); + // Emit WooCommerce store events into the Woo pipeline (ClickHouse + proxy). WooCommerce_Analytics_Tracker::configure(); @@ -310,7 +313,7 @@ public static function register_admin_menu() { add_menu_page( esc_html( $menu_title ), esc_html( $menu_title ), - 'manage_options', + Capabilities::VIEW_ANALYTICS, 'jetpack-premium-analytics-wp-admin', $render_callback, 'dashicons-chart-bar', diff --git a/projects/packages/premium-analytics/src/class-capabilities.php b/projects/packages/premium-analytics/src/class-capabilities.php new file mode 100644 index 000000000000..6ebe0f9a3785 --- /dev/null +++ b/projects/packages/premium-analytics/src/class-capabilities.php @@ -0,0 +1,108 @@ + 404 ) + ); + } + + return rest_ensure_response( get_dashboard_default_layout_for( $dashboard_name ) ); } /** @@ -94,9 +112,7 @@ function register_dashboard_default_layout_route() { array( 'methods' => \WP_REST_Server::READABLE, 'callback' => __NAMESPACE__ . '\\get_dashboard_default_layout_response', - 'permission_callback' => static function () { - return current_user_can( 'manage_options' ); - }, + '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 376393f34634..6bbdc2933bab 100644 --- a/projects/packages/premium-analytics/src/dashboard-sections.php +++ b/projects/packages/premium-analytics/src/dashboard-sections.php @@ -66,6 +66,20 @@ 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. + * + * The sibling is_woocommerce_dashboard_section_available() answers "is + * WooCommerce here"; this 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() && Capabilities::current_user_can_view_store_reports(); +} + /** * Returns the default widget layout for the WooCommerce dashboard section. * @@ -108,7 +122,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 +153,7 @@ function bootstrap_dashboard_sections() { * @return bool */ function check_dashboard_sections_permission() { - return current_user_can( 'manage_options' ); + 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 dc73887e3321..56b838151cdd 100644 --- a/projects/packages/premium-analytics/src/widget-availability.php +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -31,6 +31,17 @@ */ const WOOCOMMERCE_BOOKINGS_WIDGET_CATEGORIES = array( 'bookings' ); +/** + * 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 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' ); + /** * Removes developer-only candidates in production. * @@ -154,3 +165,50 @@ function filter_registrable_widget_types_by_plugin( $widget_candidates ) { } add_filter( REGISTRABLE_WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_registrable_widget_types_by_plugin' ); + +/** + * 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_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_view_store_reports ) { + if ( $can_view_store_reports ) { + return $widget_candidates; + } + + return array_values( + array_filter( + $widget_candidates, + static function ( $widget ) { + return ! in_array( $widget['category'] ?? '', STORE_REPORT_WIDGET_CATEGORIES, true ); + } + ) + ); +} + +/** + * Registry-time callback: hides widgets whose data the reader cannot fetch. + * + * 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 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, + Capabilities::current_user_can_view_store_reports() + ); +} + +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..cf02949fb716 100644 --- a/projects/packages/premium-analytics/src/widget-modules.php +++ b/projects/packages/premium-analytics/src/widget-modules.php @@ -26,9 +26,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' => 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 7071f0a73797..805e25ed9d6d 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' ); + Capabilities::unregister(); $this->reset_analytics_init_state(); parent::tearDown(); } @@ -94,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', array( Capabilities::class, 'map_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. */ @@ -192,6 +228,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( Capabilities::VIEW_ANALYTICS, $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. @@ -353,6 +399,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. + Capabilities::register(); + add_filter( 'user_has_cap', array( $this, 'grant_manage_options' ) ); $this->capture_doing_it_wrong(); Analytics::register_admin_menu(); @@ -385,13 +435,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..19d93e611f97 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Capabilities_Test.php @@ -0,0 +1,165 @@ +reset_analytics_capabilities(); + wp_set_current_user( 0 ); + + parent::tear_down(); + } + + /** + * Administrators reach the dashboard through manage_options, with no help from + * the Stats mapping — which is absent on platforms that never boot Stats. + */ + public function test_administrator_can_view_analytics_without_the_stats_mapping() { + $this->login_as( 'administrator' ); + + $this->assertTrue( Capabilities::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( Capabilities::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( Capabilities::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( Capabilities::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' ), + Capabilities::map_meta_caps( array( 'edit_posts' ), 'edit_posts', 1 ) + ); + } + + /** + * 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_view_store_reports() { + $user_id = $this->login_as( 'editor' ); + $this->grant_view_stats_to( $user_id ); + + $this->assertTrue( Capabilities::current_user_can_view_analytics() ); + $this->assertFalse( Capabilities::current_user_can_view_store_reports() ); + } + + /** + * Administrators keep them. + */ + public function test_administrator_can_view_store_reports() { + $this->login_as( 'administrator' ); + + $this->assertTrue( Capabilities::current_user_can_view_store_reports() ); + } + + /** + * 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. + */ + 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' ); + + $reader = $this->login_as( 'editor' ); + $this->grant_view_stats_to( $reader ); + + $this->assertSame( + $controller->check_data_permission( $request ), + 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.' + ); + + $this->login_as( 'administrator' ); + + $this->assertSame( + $controller->check_data_permission( $request ), + Capabilities::current_user_can_view_store_reports(), + '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( Capabilities::current_user_can_view_store_reports() ); + $this->assertSame( + $controller->check_data_permission( $request ), + Capabilities::current_user_can_view_store_reports(), + '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( 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 490b25b02fa3..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' ) ); } /** @@ -50,6 +88,78 @@ 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( + array( Capabilities::class, 'current_user_can_view_analytics' ), + $routes[ self::ROUTE ][0]['permission_callback'] + ); + } + + /** + * `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. */ 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..b7ac74983637 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. * @@ -41,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. + Capabilities::register(); } /** @@ -57,6 +64,10 @@ public function tear_down() { remove_all_actions( 'doing_it_wrong_run' ); remove_all_filters( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER ); + // Drops the package's mapping along with any per-user view_stats grant a + // test added; set_up() hooks the mapping again. + $this->reset_analytics_capabilities(); + wp_set_current_user( 0 ); parent::tear_down(); @@ -417,6 +428,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 +485,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 +832,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/Dashboard_Support_Routes_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Support_Routes_Test.php index 0f45058a2115..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,6 +23,7 @@ protected function tearDown(): void { $wp_rest_server = null; remove_all_actions( 'rest_api_init' ); remove_all_actions( 'init' ); + Capabilities::unregister(); 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', array( Capabilities::class, 'map_meta_caps' ) ) + ); + } } 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/REST/Notices_Controller_Test.php b/projects/packages/premium-analytics/tests/php/REST/Notices_Controller_Test.php index e144b9a965e0..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; @@ -33,12 +34,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. + Capabilities::register(); + 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() { + Capabilities::unregister(); + + 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 ); 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/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php index 30df334ddea2..ca5f0bbee8e1 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 { /** @@ -99,6 +103,24 @@ private function commerce_widget_candidates() { ); } + /** + * Candidate set spanning every store-report category, plus one served from + * elsewhere. + * + * @return array[] List of widget candidates. + */ + private function store_report_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. * @@ -258,6 +280,87 @@ public function test_uncategorized_widgets_pass_through() { ); } + /** + * 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_store_report_widgets_removed_from_a_reader_without_access() { + $this->assertSame( + array( 'jpa/traffic-chart' ), + array_column( + remove_capability_gated_widget_types( $this->store_report_widget_candidates(), false ), + 'name' + ), + 'Only the category served by another prefix survives.' + ); + } + + /** + * Administrators keep every category. + */ + public function test_store_report_widgets_kept_for_a_user_with_access() { + $this->assertSame( + $this->store_report_widget_candidates(), + remove_capability_gated_widget_types( $this->store_report_widget_candidates(), true ), + 'With prefix access no candidate is dropped.' + ); + } + + /** + * 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. + */ + 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..3c39af7a1aae 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( + array( Capabilities::class, '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..d2652d855586 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/traits/trait-analytics-capabilities.php @@ -0,0 +1,94 @@ + 'pa-' . $role, + 'user_pass' => 'password', + 'user_email' => 'pa-' . $role . '@example.com', + 'role' => $role, + ) + ); + + wp_set_current_user( $user_id ); + + 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. + * + * @param int $granted_user_id User who should hold `view_stats`. + * @return void + */ + protected function grant_view_stats_to( $granted_user_id ) { + $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() { + Capabilities::unregister(); + + if ( null !== $this->granted_view_stats_filter ) { + remove_filter( 'map_meta_cap', $this->granted_view_stats_filter, 10 ); + $this->granted_view_stats_filter = null; + } + } +}