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;
+ }
+ }
+}