diff --git a/includes/content-gate/class-access-rules.php b/includes/content-gate/class-access-rules.php index fd55ce00b5..42a0f2f8f9 100644 --- a/includes/content-gate/class-access-rules.php +++ b/includes/content-gate/class-access-rules.php @@ -355,11 +355,22 @@ public static function get_subscription_products_options() { * Whether the user has an active subscription for one of the given products. * Also checks if the user is a member of a group subscription with the required products. * + * Note: `$strict` only constrains the built-in ownership / group-membership checks. + * The `newspack_access_rules_has_active_subscription` filter is always applied and + * its return value is the final result, so a third-party filter callback can grant + * access even when `$strict` is true. Filter authors should opt in to the 4th `$strict` + * arg (`accepted_args` >= 4) and respect it — e.g., short-circuit and return + * `$has_subscription` unchanged when `$strict` is true and the access claim isn't + * strictly an owned subscription. Otherwise callers using `$strict` to distinguish + * owner-vs-member access (e.g., `Content_Gate` source labels) may misclassify + * filter-granted access as local ownership. + * * @param int $user_id User ID. * @param array $product_ids Required product IDs. + * @param bool $strict If true, only consider active subscriptions owned by $user_id (ignore group subscription memberships). * @return bool */ - public static function has_active_subscription( $user_id, $product_ids ) { + public static function has_active_subscription( $user_id, $product_ids, $strict = false ) { $has_subscription = false; // Check user's own subscriptions. @@ -368,7 +379,7 @@ public static function has_active_subscription( $user_id, $product_ids ) { } // Check group subscriptions the user is a member of. - if ( ! $has_subscription && function_exists( 'wcs_get_subscription' ) ) { + if ( ! $strict && ! $has_subscription && function_exists( 'wcs_get_subscription' ) ) { $group_subscriptions = Group_Subscription::get_group_subscriptions_for_user( $user_id ); foreach ( $group_subscriptions as $subscription ) { if ( ! $subscription || ! $subscription->has_status( WooCommerce_Connection::ACTIVE_SUBSCRIPTION_STATUSES ) ) { @@ -395,8 +406,9 @@ public static function has_active_subscription( $user_id, $product_ids ) { * @param bool $has_subscription Whether the user has an active subscription. * @param int $user_id User ID. * @param array $product_ids Required product IDs. + * @param bool $strict If true, only consider active subscriptions owned by $user_id (ignore group subscription memberships). */ - return apply_filters( 'newspack_access_rules_has_active_subscription', $has_subscription, $user_id, $product_ids ); + return apply_filters( 'newspack_access_rules_has_active_subscription', $has_subscription, $user_id, $product_ids, $strict ); } /** diff --git a/includes/content-gate/class-institution.php b/includes/content-gate/class-institution.php index 4501c7c6a0..5a94818fcd 100644 --- a/includes/content-gate/class-institution.php +++ b/includes/content-gate/class-institution.php @@ -235,6 +235,113 @@ public static function evaluate( $user_id, $institution_ids ) { return false; } + /** + * Per-request cache of [inst_id => decoded_name] maps. + * + * @var array> + */ + private static $names_cache = []; + + /** + * Reset the per-request matching-names cache. + * + * Tests, CLI workers, and invalidation hooks call this to bust the memoization in + * `get_matching_names_for_user()` / `get_matching_ids_for_user()`. Distinct from + * `invalidate_cache()`, which clears the underlying institutions transient. + */ + public static function reset_matching_cache() { + self::$names_cache = []; + } + + /** + * Get the sorted, deduplicated names of institutions whose rules a user matches. + * + * Memoized per request via {@see self::get_matching_map_for_user()} — see that helper + * for cache scope, the IP/cookie context dependency, and invalidation. + * + * @param int $user_id User ID. + * @param array|null $institution_filter Optional list of institution post IDs. If non-empty, only + * institutions whose ID is in the list are considered. + * Pass null or an empty array to scan every cached institution. + * + * @return string[] Sorted, deduplicated institution names. + */ + public static function get_matching_names_for_user( $user_id, $institution_filter = null ) { + $map = self::get_matching_map_for_user( $user_id, $institution_filter ); + $names = array_values( array_unique( array_values( $map ) ) ); + sort( $names, SORT_NATURAL | SORT_FLAG_CASE ); + return $names; + } + + /** + * Get the IDs of institutions whose rules a user matches. + * + * Returns institution post IDs. Shares the per-request cache with + * {@see self::get_matching_names_for_user()}. Suitable for callers that want a stable, + * non-PII identifier (e.g., GA4 anonymized labels) and don't need the display name. + * + * @param int $user_id User ID. + * @param array|null $institution_filter Optional list of institution post IDs (same semantics + * as {@see self::get_matching_names_for_user()}). + * + * @return int[] Sorted institution post IDs. + */ + public static function get_matching_ids_for_user( $user_id, $institution_filter = null ) { + $ids = array_keys( self::get_matching_map_for_user( $user_id, $institution_filter ) ); + sort( $ids, SORT_NUMERIC ); + return $ids; + } + + /** + * Build the [inst_id => decoded_name] map for the user, memoized per request. + * + * Cache key includes `get_current_user_id()` because `user_matches_institution()`'s IP + * branch is context-dependent on the current visitor (see the comment in that method). + * Without that the same `$user_id` could legitimately resolve to different sets across + * requests in a long-running worker that swaps the current user. + * + * IDs are read with `get_post_field( 'post_title', ... )` rather than `get_the_title( ... )` + * so the value going to GA4/ESP is a raw post title, not one that's been through + * `the_title` filters (texturization, third-party plugins) that can introduce entities + * or markup. + * + * @param int $user_id User ID. + * @param array|null $institution_filter Optional list of institution post IDs. + * + * @return array Map of institution post ID to decoded post title. + */ + private static function get_matching_map_for_user( $user_id, $institution_filter = null ) { + $user_id = (int) $user_id; + + // Normalize the filter so [], null, and unsorted/duplicate inputs share a cache key. + $normalized_filter = is_array( $institution_filter ) && ! empty( $institution_filter ) + ? array_values( array_unique( array_map( 'absint', $institution_filter ) ) ) + : null; + if ( null !== $normalized_filter ) { + sort( $normalized_filter, SORT_NUMERIC ); + } + // Include the current user in the cache key because the IP-rule branch of + // user_matches_institution() short-circuits when $user_id !== get_current_user_id(). + $cache_key = $user_id . '|' . get_current_user_id() . '|' . ( null === $normalized_filter ? '' : implode( ',', $normalized_filter ) ); + if ( isset( self::$names_cache[ $cache_key ] ) ) { + return self::$names_cache[ $cache_key ]; + } + + $map = []; + foreach ( self::get_cached_institutions() as $inst_id => $rules ) { + $inst_id = (int) $inst_id; + if ( null !== $normalized_filter && ! in_array( $inst_id, $normalized_filter, true ) ) { + continue; + } + if ( self::user_matches_institution( $user_id, $rules ) ) { + $map[ $inst_id ] = html_entity_decode( (string) \get_post_field( 'post_title', $inst_id ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + } + } + + self::$names_cache[ $cache_key ] = $map; + return $map; + } + /** * Check if a user matches an institution's rules (OR logic). * @@ -254,12 +361,22 @@ public static function user_matches_institution( $user_id, $rules, $uncached = f if ( ! empty( $rules['ip_range'] ) ) { // IP evaluation is page-cache-safe only when the response would be uncached anyway: - // the caller flagged it as such, the visitor is logged in, or the visitor carries - // the IP-access bypass cookie. A first-time anonymous on-campus visitor landing - // directly on a gated post matches none of these and will see the gate — they must - // first complete the IP check at /institutional-access (or ?institutional-access=1) - // to set the cookie before subsequent gated requests can evaluate their IP. - $is_uncached = $uncached || ! empty( $user_id ) || IP_Access_Rule::is_cookie_set(); + // the caller flagged it as such, the *current visitor* is logged in (so the IP we + // would read is theirs), or the visitor carries the IP-access bypass cookie. We + // require $user_id === get_current_user_id() to avoid attributing the requestor's + // IP to a different user during background metadata sync (admin/cron/webhook). A + // first-time anonymous on-campus visitor landing directly on a gated post matches + // none of these and will see the gate — they must first complete the IP check at + // /institutional-access (or ?institutional-access=1) to set the cookie before + // subsequent gated requests can evaluate their IP. + // + // Caveat: the IP_Access_Rule::is_cookie_set() disjunct accepts any non-current + // $user_id when the *current visitor* carries the cache-bypass cookie, so a + // cookie-bearing on-campus visitor can still cause the current request's IP to + // be matched against another user's institution range. This is a narrow, + // pre-existing edge case (the cookie is only set after the visitor has already + // passed an institutional-access check on this site). + $is_uncached = $uncached || ( ! empty( $user_id ) && (int) $user_id === get_current_user_id() ) || IP_Access_Rule::is_cookie_set(); if ( $is_uncached && IP_Access_Rule::ip_matches_ranges( IP_Access_Rule::get_visitor_ip(), $rules['ip_range'] ) ) { return true; } diff --git a/includes/plugins/class-teams-for-memberships.php b/includes/plugins/class-teams-for-memberships.php index 3d8a1dcc17..849b075dca 100644 --- a/includes/plugins/class-teams-for-memberships.php +++ b/includes/plugins/class-teams-for-memberships.php @@ -13,6 +13,7 @@ use Newspack\Reader_Activation\Sync\Woocommerce as Sync_WooCommerce; use Newspack\Reader_Activation\Sync\Metadata as Sync_Metadata; use Newspack\Reader_Activation\Contact_Sync; +use Newspack\Reader_Activation\Integrations; /** * Main class. @@ -187,7 +188,11 @@ public static function handle_esp_sync_contact( $contact ) { return $contact; } - $filtered_enabled_fields = Sync_Metadata::filter_enabled_fields( [ 'woo_team' ] ); + $esp = Integrations::get_integration( 'esp' ); + if ( ! $esp ) { + return $contact; + } + $filtered_enabled_fields = $esp->filter_enabled_outgoing_fields( [ 'woo_team' ] ); if ( empty( $contact['email'] ) ) { return $contact; diff --git a/includes/plugins/google-site-kit/class-googlesitekit.php b/includes/plugins/google-site-kit/class-googlesitekit.php index dc7f5e367a..bc23bb17ce 100644 --- a/includes/plugins/google-site-kit/class-googlesitekit.php +++ b/includes/plugins/google-site-kit/class-googlesitekit.php @@ -260,6 +260,14 @@ function( $category ) { // If reader has any currently active non-donation subscriptions. $params['is_subscriber'] = empty( $reader_data['active_subscriptions'] ) ? 'no' : 'yes'; + // Content access groups: anonymized identifiers for the user's active group + // subscriptions and matching institutions. See get_user_group_labels() for + // why we send IDs to GA4 rather than the human-readable names. + if ( Content_Gate::is_newspack_feature_enabled() ) { + $group_labels = self::get_user_group_labels( $current_user ); + $params['group'] = empty( $group_labels ) ? 'none' : implode( ', ', $group_labels ); + } + /** * Filters the custom parameters passed to GA4. * @@ -268,6 +276,48 @@ function( $category ) { return apply_filters( 'newspack_ga4_custom_parameters', $params ); } + /** + * Build the GA4 `group` parameter value for a user. + * + * The value is a sorted, comma-delimited list of anonymized identifiers for + * active group subscriptions the user owns or is a member of, plus institutions + * whose rules the user matches via any means. + * + * We emit anonymized IDs (`Group {sub_id}`, `Institution {inst_id}`) rather than + * publisher-facing display names because the unnamed-group fallback in + * `Group_Subscription_Settings` synthesizes a name from the owner's billing full + * name — sending that to GA4 would leak PII for every member of an unnamed group. + * The ESP path keeps the human-readable names; only the GA4 surface is anonymized. + * + * Both lookups are delegated to per-request-memoized helpers in `Group_Subscription` + * and `Institution`, so repeat calls within the same request are cheap. Memoization + * is deliberately request-scoped because the institution branch is per-visitor. + * + * @param \WP_User $user The user to inspect. + * @return string[] Sorted, deduplicated anonymized labels. + */ + private static function get_user_group_labels( $user ) { + if ( ! $user || ! $user->ID ) { + return []; + } + // Match the framing of the surrounding params (`is_reader`, `is_subscriber`): + // only attribute groups to actual readers, not admins/editors. + if ( ! Reader_Activation::is_user_reader( $user ) ) { + return []; + } + $user_id = (int) $user->ID; + + $labels = []; + foreach ( Group_Subscription::get_group_ids_for_user( $user_id ) as $sub_id ) { + $labels[] = 'Group ' . $sub_id; + } + foreach ( Institution::get_matching_ids_for_user( $user_id ) as $inst_id ) { + $labels[] = 'Institution ' . $inst_id; + } + sort( $labels, SORT_NATURAL | SORT_FLAG_CASE ); + return $labels; + } + /** * Filter the GA config to add custom parameters. * diff --git a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-myaccount.php b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-myaccount.php index 5d5001faa4..7eec758800 100644 --- a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-myaccount.php +++ b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-myaccount.php @@ -284,6 +284,13 @@ public static function inject_member_group_subscriptions( $subscriptions, $user_ if ( ! function_exists( 'is_account_page' ) || ! \is_account_page() ) { return $subscriptions; } + // Don't add Group Subscription features to My Account when Woo Memberships + // is active. TODO: Remove this once Access Control is fully released. + // Mirrors the suppression that used to live in Group_Subscription::is_group_subscription(), + // preserved here at the UI layer now that data-layer callers always see the canonical state. + if ( Memberships::is_active() ) { + return $subscriptions; + } $existing_ids = array_keys( $subscriptions ); $group_subscriptions = Group_Subscription::get_group_subscriptions_for_user( $user_id ); foreach ( $group_subscriptions as $group_subscription ) { diff --git a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription.php b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription.php index 6db12f0ab1..7c08bf70b3 100644 --- a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription.php +++ b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription.php @@ -18,6 +18,49 @@ class Group_Subscription { */ const GROUP_SUBSCRIPTION_USER_META_KEY = '_newspack_group_subscription'; + /** + * Per-request cache of [sub_id => decoded_name] maps, keyed by user_id + product filter. + * + * @var array> + */ + private static $names_cache = []; + + /** + * Reset the per-request names cache. + * + * Tests, CLI workers, and invalidation hooks call this to bust the static + * memoization in `get_group_names_for_user()` / `get_group_ids_for_user()`. + * No-op if nothing is cached. + */ + public static function reset_cache() { + self::$names_cache = []; + } + + /** + * Register cache invalidation hooks. Called once at plugin load. + */ + public static function init() { + // Subscription status changes (WCS hook fires for any active <-> non-active transition). + \add_action( 'woocommerce_subscription_status_updated', [ __CLASS__, 'reset_cache' ] ); + // Group member meta add / remove. + \add_action( 'added_user_meta', [ __CLASS__, 'maybe_reset_cache_on_user_meta' ], 10, 3 ); + \add_action( 'updated_user_meta', [ __CLASS__, 'maybe_reset_cache_on_user_meta' ], 10, 3 ); + \add_action( 'deleted_user_meta', [ __CLASS__, 'maybe_reset_cache_on_user_meta' ], 10, 3 ); + } + + /** + * Reset the names cache only when a user-meta change touches our group key. + * + * @param int|int[] $meta_ids Meta ID(s). + * @param int $object_id Object ID. + * @param string $meta_key Meta key. + */ + public static function maybe_reset_cache_on_user_meta( $meta_ids, $object_id, $meta_key ) { + if ( self::GROUP_SUBSCRIPTION_USER_META_KEY === $meta_key ) { + self::reset_cache(); + } + } + /** * Check if a subscription is a group subscription. * @@ -229,7 +272,18 @@ public static function get_group_subscriptions_for_user( $user_id, $ids_only = f $subscription_ids = array_map( 'absint', \get_user_meta( $user_id, self::GROUP_SUBSCRIPTION_USER_META_KEY, false ) ); $subscriptions = []; foreach ( $subscription_ids as $subscription_id ) { - $subscriptions[] = $ids_only ? $subscription_id : \wcs_get_subscription( $subscription_id ); + $subscription = \wcs_get_subscription( $subscription_id ); + if ( ! $subscription ) { + continue; + } + // Check the group-enabled meta directly rather than calling self::is_group_subscription(), + // which has a context-dependent side effect on the My Account page when WC Memberships + // is active. Data-layer callers must always see the canonical state. + $settings = Group_Subscription_Settings::get_subscription_settings( $subscription ); + if ( empty( $settings['enabled'] ) ) { + continue; + } + $subscriptions[] = $ids_only ? $subscription_id : $subscription; } /** @@ -240,4 +294,139 @@ public static function get_group_subscriptions_for_user( $user_id, $ids_only = f */ return apply_filters( 'newspack_group_subscriptions_for_user', $subscriptions, $user_id ); } + + /** + * Get the sorted, deduplicated names of active group subscriptions a user owns or is a member of. + * + * Memoized per request via {@see self::get_settings_map_for_user()} — see that helper for + * cache scope, invalidation hooks, and `reset_cache()`. + * + * @param int $user_id User ID. + * @param array|null $product_filter Optional list of product IDs. If non-empty, only subscriptions + * containing at least one of these products contribute a name. + * Pass null or an empty array to include every active group sub. + * + * @return string[] Sorted, deduplicated group names. + */ + public static function get_group_names_for_user( $user_id, $product_filter = null ) { + $map = self::get_settings_map_for_user( $user_id, $product_filter ); + $names = array_values( array_unique( array_values( $map ) ) ); + sort( $names, SORT_NATURAL | SORT_FLAG_CASE ); + return $names; + } + + /** + * Get the IDs of active group subscriptions a user owns or is a member of. + * + * Returns subscription post IDs (not product IDs). Shares the per-request cache with + * {@see self::get_group_names_for_user()}, so calling both for the same user is cheap. + * Suitable for downstream consumers that need an anonymous identifier (e.g., GA4) and + * want to avoid serializing publisher-facing group names. + * + * @param int $user_id User ID. + * @param array|null $product_filter Optional list of product IDs. Same semantics as + * {@see self::get_group_names_for_user()}. + * + * @return int[] Sorted subscription IDs. + */ + public static function get_group_ids_for_user( $user_id, $product_filter = null ) { + $ids = array_keys( self::get_settings_map_for_user( $user_id, $product_filter ) ); + sort( $ids, SORT_NUMERIC ); + return $ids; + } + + /** + * Build the [sub_id => decoded_name] map for the user, memoized per request. + * + * Cache scope: function-local static, keyed by user ID + normalized product filter. + * The cache lives for the duration of the PHP request. Hooks registered in {@see self::init()} + * call {@see self::reset_cache()} when subscriptions or group-member meta change so a + * long-running CLI worker doesn't serve stale data across jobs. Tests can call + * `reset_cache()` directly between cases. + * + * Gifting note: `WooCommerce_Connection::get_active_subscriptions_for_user()` excludes + * gifted subscriptions where the user isn't the recipient. The member branch + * (`get_group_subscriptions_for_user()`) doesn't apply that filter — so a gifted group + * subscription could be present via membership even when ownership would exclude it. + * This mirrors the existing asymmetry in `Access_Rules::has_active_subscription()`. + * + * @param int $user_id User ID. + * @param array|null $product_filter Optional list of product IDs (same semantics as the public APIs). + * + * @return array Map of subscription post ID to decoded group name. + */ + private static function get_settings_map_for_user( $user_id, $product_filter = null ) { + $user_id = (int) $user_id; + if ( ! $user_id || ! function_exists( 'wcs_get_subscription' ) ) { + return []; + } + if ( ! Reader_Activation::is_user_reader( \get_user_by( 'id', $user_id ) ) ) { + return []; + } + + // Normalize the filter so [], null, and unsorted/duplicate inputs share a cache key. + $normalized_filter = is_array( $product_filter ) && ! empty( $product_filter ) + ? array_values( array_unique( array_map( 'absint', $product_filter ) ) ) + : null; + if ( null !== $normalized_filter ) { + sort( $normalized_filter, SORT_NUMERIC ); + } + $cache_key = $user_id . '|' . ( null === $normalized_filter ? '' : implode( ',', $normalized_filter ) ); + if ( isset( self::$names_cache[ $cache_key ] ) ) { + return self::$names_cache[ $cache_key ]; + } + + $candidates = []; + + // Owned active subscriptions, already filtered by status (and product, if provided) and gifting. + $owned_ids = WooCommerce_Connection::get_active_subscriptions_for_user( + $user_id, + null === $normalized_filter ? [] : $normalized_filter + ); + foreach ( $owned_ids as $sub_id ) { + $sub = \wcs_get_subscription( $sub_id ); + if ( $sub ) { + $candidates[ $sub->get_id() ] = $sub; + } + } + + // Member subscriptions (via user meta). Apply status + product filters manually. + foreach ( self::get_group_subscriptions_for_user( $user_id ) as $sub ) { + $sub_id = $sub->get_id(); + if ( isset( $candidates[ $sub_id ] ) ) { + continue; + } + if ( ! $sub->has_status( WooCommerce_Connection::ACTIVE_SUBSCRIPTION_STATUSES ) ) { + continue; + } + if ( null !== $normalized_filter ) { + $matches = false; + foreach ( $normalized_filter as $product_id ) { + if ( $sub->has_product( $product_id ) ) { + $matches = true; + break; + } + } + if ( ! $matches ) { + continue; + } + } + $candidates[ $sub_id ] = $sub; + } + + $map = []; + foreach ( $candidates as $sub_id => $sub ) { + // Read settings once: it's the authoritative source for `enabled` and `name`, + // and is_group_subscription() would call this internally anyway. + $settings = Group_Subscription_Settings::get_subscription_settings( $sub ); + if ( empty( $settings['enabled'] ) ) { + continue; + } + $map[ $sub_id ] = html_entity_decode( (string) $settings['name'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + } + + self::$names_cache[ $cache_key ] = $map; + return $map; + } } +Group_Subscription::init(); diff --git a/includes/reader-activation/sync/contact-metadata/class-content-gate.php b/includes/reader-activation/sync/contact-metadata/class-content-gate.php index 59f8a963dc..7a9ac58352 100644 --- a/includes/reader-activation/sync/contact-metadata/class-content-gate.php +++ b/includes/reader-activation/sync/contact-metadata/class-content-gate.php @@ -10,6 +10,8 @@ use Newspack\Reader_Activation\Sync\Contact_Metadata; use Newspack\Access_Rules; use Newspack\Content_Gate as Content_Gate_CPT; +use Newspack\Group_Subscription; +use Newspack\Institution; use Newspack\User_Gate_Access; defined( 'ABSPATH' ) || exit; @@ -60,6 +62,7 @@ public static function get_fields() { return [ 'Content_Access' => 'Content Access', 'Content_Access_Source' => 'Content Access Source', + 'Content_Access_Group' => 'Content Access Group', ]; } @@ -80,6 +83,7 @@ public function get_metadata() { return [ 'Content_Access' => '', 'Content_Access_Source' => '', + 'Content_Access_Group' => '', ]; } @@ -88,9 +92,11 @@ public function get_metadata() { $evaluations[] = User_Gate_Access::evaluate_gate_for_user( $gate, $this->user->ID ); } + $user_id = $this->user->ID; return [ 'Content_Access' => self::has_content_access( $evaluations ) ? 'Yes' : 'No', - 'Content_Access_Source' => implode( ', ', self::get_access_source_labels( $evaluations, $this->user->ID ) ), + 'Content_Access_Source' => implode( ', ', self::collect_labels( $evaluations, $user_id, [ self::class, 'get_source_labels' ] ) ), + 'Content_Access_Group' => implode( ', ', self::collect_labels( $evaluations, $user_id, [ self::class, 'get_group_labels' ] ) ), ]; } @@ -129,14 +135,15 @@ private static function has_content_access( $evaluations ) { } /** - * Get deduplicated, sorted source labels from gate evaluations. + * Walk gate evaluations and collect labels via a per-rule resolver. * - * @param array $evaluations Results from User_Gate_Access::evaluate_gate_for_user(). - * @param int $user_id User ID. - * @return array Sorted source label strings. + * @param array $evaluations Results from User_Gate_Access::evaluate_gate_for_user(). + * @param int $user_id User ID. + * @param callable $resolver Receives ($slug, $value, $user_id) and returns string[] of labels. + * @return array Sorted, deduplicated labels. */ - private static function get_access_source_labels( $evaluations, $user_id ) { - $sources = []; + private static function collect_labels( $evaluations, $user_id, $resolver ) { + $labels_set = []; foreach ( $evaluations as $result ) { if ( ! $result['can_bypass'] ) { @@ -150,14 +157,14 @@ private static function get_access_source_labels( $evaluations, $user_id ) { if ( ! $rule['passes'] ) { continue; } - foreach ( self::get_source_labels( $rule['slug'], $rule['value'], $user_id ) as $label ) { - $sources[ $label ] = true; + foreach ( $resolver( $rule['slug'], $rule['value'], $user_id ) as $label ) { + $labels_set[ $label ] = true; } } } } - $labels = array_keys( $sources ); + $labels = array_keys( $labels_set ); sort( $labels, SORT_NATURAL | SORT_FLAG_CASE ); return $labels; } @@ -173,27 +180,73 @@ private static function get_access_source_labels( $evaluations, $user_id ) { private static function get_source_labels( $slug, $value, $user_id ) { switch ( $slug ) { case 'subscription': - if ( is_array( $value ) && function_exists( 'wc_get_product' ) ) { + if ( ! is_array( $value ) || ! function_exists( 'wc_get_product' ) ) { + return [ 'subscription' ]; + } + // Determine ownership first so an owner of a sub matching an + // "any subscription" rule (empty $value) isn't mislabeled as + // `group` by the non-strict check below. + if ( Access_Rules::has_active_subscription( $user_id, $value, true ) ) { $names = []; foreach ( $value as $product_id ) { - if ( Access_Rules::has_active_subscription( $user_id, [ $product_id ] ) ) { + if ( Access_Rules::has_active_subscription( $user_id, [ $product_id ], true ) ) { $product = wc_get_product( $product_id ); if ( $product ) { - $names[] = $product->get_name(); + $names[] = html_entity_decode( (string) $product->get_name(), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } } } - if ( ! empty( $names ) ) { - return $names; - } + return ! empty( $names ) ? $names : [ 'subscription' ]; } - return [ 'Subscription' ]; + // Not an owner — check group subscription membership. + if ( Access_Rules::has_active_subscription( $user_id, $value ) ) { + return [ 'group' ]; + } + // They might still have access via the + // `newspack_access_rules_has_active_subscription` filter hook. + return [ 'subscription' ]; case 'email_domain': return [ 'domain' ]; case 'institution': - return [ 'group' ]; + return [ 'institution' ]; + + case 'reader_data': + return [ 'reader_data' ]; + + default: + return []; + } + } + + /** + * Map an access rule slug and value to group labels. + * + * Delegates name resolution to `Group_Subscription::get_group_names_for_user()` and + * `Institution::get_matching_names_for_user()` so the GA4 helper and other callers + * share the same logic (memoization, status filters, name decoding). + * + * @param string $slug Rule slug. + * @param mixed $value Rule value. + * @param int $user_id User ID. + * @return array Group labels. + */ + private static function get_group_labels( $slug, $value, $user_id ) { + switch ( $slug ) { + case 'subscription': + // An empty/non-array $value mirrors Access_Rules::has_active_subscription's + // "any active subscription" semantics — every active group sub matches. + $product_filter = is_array( $value ) && ! empty( $value ) ? $value : null; + return Group_Subscription::get_group_names_for_user( $user_id, $product_filter ); + + case 'institution': + // A malformed institution rule (missing/empty/scalar value) matches everyone + // per Institution::evaluate(), but there's no specific institution to attribute. + if ( ! is_array( $value ) || empty( $value ) ) { + return []; + } + return Institution::get_matching_names_for_user( $user_id, $value ); default: return []; diff --git a/tests/unit-tests/content-gate/class-content-gate-metadata.php b/tests/unit-tests/content-gate/class-content-gate-metadata.php index ba4a6f0332..dc0efd535d 100644 --- a/tests/unit-tests/content-gate/class-content-gate-metadata.php +++ b/tests/unit-tests/content-gate/class-content-gate-metadata.php @@ -6,6 +6,9 @@ */ use Newspack\Content_Gate; +use Newspack\Group_Subscription; +use Newspack\Group_Subscription_Settings; +use Newspack\Institution; use Newspack\Reader_Activation; use Newspack\Reader_Activation\Sync\Contact_Metadata\Content_Gate as Content_Gate_Metadata; @@ -23,12 +26,43 @@ class Newspack_Test_Content_Gate_Metadata extends WP_UnitTestCase { */ private static $user_id; + /** + * Owner user ID for group subscriptions. + * + * @var int + */ + private static $owner_id; + + /** + * Institution post IDs to delete during tear_down. Institution::create() inserts + * real posts that aren't tracked by $this->factory, so we manage cleanup explicitly. + * + * @var int[] + */ + private $institution_ids = []; + + /** + * Set up the WC mocks once for the class. + */ + public static function set_up_before_class() { + parent::set_up_before_class(); + require_once dirname( __DIR__, 2 ) . '/mocks/wc-mocks.php'; + } + /** * Set up before each test. */ public function set_up() { parent::set_up(); Content_Gate_Metadata::reset_cache(); + Group_Subscription::reset_cache(); + Institution::reset_matching_cache(); + + // Reset mock WC databases. + global $subscriptions_database, $products_database; + $subscriptions_database = []; + $products_database = []; + self::$user_id = $this->factory->user->create( [ 'role' => 'subscriber', @@ -36,6 +70,101 @@ public function set_up() { ] ); Reader_Activation::set_reader_verified( self::$user_id ); + self::$owner_id = $this->factory->user->create( + [ + 'role' => 'subscriber', + 'user_email' => 'owner@example.com', + ] + ); + } + + /** + * Clean up after each test. + */ + public function tear_down() { + delete_user_meta( self::$user_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY ); + + // Delete institution posts created during the test so they don't leak into later tests. + foreach ( $this->institution_ids as $post_id ) { + wp_delete_post( $post_id, true ); + } + $this->institution_ids = []; + delete_transient( Institution::TRANSIENT_KEY ); + + Group_Subscription::reset_cache(); + Institution::reset_matching_cache(); + parent::tear_down(); + } + + /** + * Helper to create a mock product and return its ID. + * + * @param int $product_id Product ID. + * @param string $name Product name. + * @return int Product ID. + */ + private function create_mock_product( $product_id, $name ) { + \wc_create_mock_product( + [ + 'id' => $product_id, + 'name' => $name, + ] + ); + return $product_id; + } + + /** + * Helper to create a subscription owned by a user. + * + * @param int $owner_id Owner user ID. + * @param array $product_ids Product IDs the subscription covers. + * @param array $overrides Optional overrides for the subscription data. + * @return \WC_Subscription + */ + private function create_subscription( $owner_id, $product_ids, $overrides = [] ) { + return \wcs_create_subscription( + array_merge( + [ + 'customer_id' => $owner_id, + 'status' => 'active', + 'billing_period' => 'month', + 'products' => $product_ids, + ], + $overrides + ) + ); + } + + /** + * Helper to create a group subscription with a name and add a member. + * + * @param int $owner_id Owner user ID. + * @param int $member_id Member user ID to add. + * @param array $product_ids Product IDs the subscription covers. + * @param string $group_name Group display name. + * @param array $overrides Optional subscription overrides (e.g., 'status'). + * @return \WC_Subscription + */ + private function create_group_subscription_with_member( $owner_id, $member_id, $product_ids, $group_name, $overrides = [] ) { + $sub = $this->create_subscription( $owner_id, $product_ids, $overrides ); + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', 'yes' ); + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'name', $group_name ); + add_user_meta( $member_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY, $sub->get_id() ); + return $sub; + } + + /** + * Helper to create an institution post with given rules. + * + * @param string $title Title. + * @param array $rules Institution rules (email_domain, ip_range, reader_data). + * @return int Institution post ID. + */ + private function create_institution( $title, $rules ) { + $id = Institution::create( $title, '', $rules ); + $this->institution_ids[] = $id; + Institution::invalidate_cache(); + return $id; } /** @@ -256,7 +385,7 @@ public function test_institution_rule_source() { $result = $this->get_metadata_for_user( self::$user_id ); $this->assertEquals( 'Yes', $result['Content_Access'], 'User matching institution should have access.' ); - $this->assertEquals( 'group', $result['Content_Access_Source'], 'Source should be "group" for institution rule.' ); + $this->assertEquals( 'institution', $result['Content_Access_Source'], 'Source should be "institution" for institution rule.' ); } /** @@ -286,4 +415,497 @@ public function test_or_logic_between_groups() { $this->assertEquals( 'Yes', $result['Content_Access'], 'User should have access when one group passes (OR logic).' ); $this->assertEquals( 'domain', $result['Content_Access_Source'] ); } + + // ------------------------------------------------------------------------- + // Content_Access_Source: subscription, group, filtered, domain, institution + // ------------------------------------------------------------------------- + + /** + * Test that an owned active subscription produces the product name as source. + */ + public function test_subscription_owned_source() { + $product_id = $this->create_mock_product( 501, 'Premium Plan' ); + $this->create_subscription( self::$user_id, [ $product_id ] ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Subscription Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'], 'Owner should have access via own subscription.' ); + $this->assertEquals( 'Premium Plan', $result['Content_Access_Source'], 'Source should be the product name for an owned subscription.' ); + } + + /** + * Test that owning multiple matching subscriptions emits each product name, sorted. + */ + public function test_subscription_owned_multiple_products_source() { + $pro_id = $this->create_mock_product( 502, 'Pro Plan' ); + $ent_id = $this->create_mock_product( 503, 'Enterprise Plan' ); + $this->create_subscription( self::$user_id, [ $pro_id, $ent_id ] ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $pro_id, $ent_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Multi Subscription Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'] ); + $this->assertEquals( 'Enterprise Plan, Pro Plan', $result['Content_Access_Source'], 'Source should list all matched product names, sorted.' ); + } + + /** + * Test that group membership (not ownership) produces 'group' as source. + */ + public function test_subscription_group_source() { + $product_id = $this->create_mock_product( 504, 'Group Plan' ); + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $product_id ], 'ACME Corp' ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Group Subscription Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'], 'Group member should have access.' ); + $this->assertEquals( 'group', $result['Content_Access_Source'], 'Source should be "group" for group-membership access.' ); + } + + /** + * Empty-value subscription rule ("any subscription") with an owned (non-group) sub. + * Source should fall back to "subscription" — never `group`, since the user owns it. + */ + public function test_subscription_owned_source_for_empty_rule_value() { + $product_id = $this->create_mock_product( 530, 'Any Plan' ); + $this->create_subscription( self::$user_id, [ $product_id ] ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [], + ], + ], + ]; + $this->create_gate_with_rules( 'Any Subscription Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'] ); + $this->assertEquals( 'subscription', $result['Content_Access_Source'], 'Owners should be labeled "subscription", not "group", when the rule value is empty.' ); + } + + /** + * Test the 'subscription' fallback when access is granted only via the + * `newspack_access_rules_has_active_subscription` filter (no real product or group match). + * + * Uses an *unregistered* product ID so that wc_get_product() returns false + * during the strict per-product name lookup — the function then naturally + * falls through to `[ 'subscription' ]` regardless of the filter being called + * once or many times. This avoids relying on internal evaluation ordering. + */ + public function test_subscription_filter_source() { + $product_id = 505; // Intentionally unregistered — wc_get_product() will return false. + + $callback = '__return_true'; + add_filter( 'newspack_access_rules_has_active_subscription', $callback ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Filtered Subscription Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + remove_filter( 'newspack_access_rules_has_active_subscription', $callback ); + + $this->assertEquals( 'Yes', $result['Content_Access'], 'Filter should grant access.' ); + $this->assertEquals( 'subscription', $result['Content_Access_Source'], 'Source should fall back to "subscription" when filter grants access without a registered product.' ); + } + + // ------------------------------------------------------------------------- + // Content_Access_Group + // ------------------------------------------------------------------------- + + /** + * Test that a group subscription's name appears in Content_Access_Group. + */ + public function test_group_label_for_group_subscription() { + $product_id = $this->create_mock_product( 510, 'Group Plan' ); + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $product_id ], 'ACME Corp' ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Group Subscription Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'] ); + $this->assertEquals( 'ACME Corp', $result['Content_Access_Group'], 'Group name should appear in Content_Access_Group.' ); + } + + /** + * Test that membership in multiple group subscriptions produces sorted names. + */ + public function test_group_label_with_multiple_groups() { + $pid_one = $this->create_mock_product( 511, 'Plan One' ); + $pid_two = $this->create_mock_product( 512, 'Plan Two' ); + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $pid_one ], 'Beta Group' ); + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $pid_two ], 'Alpha Group' ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $pid_one, $pid_two ], + ], + ], + ]; + $this->create_gate_with_rules( 'Multi Group Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Alpha Group, Beta Group', $result['Content_Access_Group'], 'Multiple group names should appear sorted naturally.' ); + } + + /** + * Test that a cancelled group subscription's name is excluded. + */ + public function test_group_label_excludes_cancelled_group_subscription() { + $product_id = $this->create_mock_product( 513, 'Active Plan' ); + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $product_id ], 'Active Group' ); + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $product_id ], 'Cancelled Group', [ 'status' => 'cancelled' ] ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Mixed Status Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Active Group', $result['Content_Access_Group'], 'Cancelled group should not contribute a label.' ); + } + + /** + * Test that an owned regular (non-group) subscription does NOT produce a group label. + */ + public function test_group_label_empty_for_owned_subscription() { + $product_id = $this->create_mock_product( 514, 'Solo Plan' ); + $this->create_subscription( self::$user_id, [ $product_id ] ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Solo Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'] ); + $this->assertEmpty( $result['Content_Access_Group'], 'Owned non-group subscriptions should not contribute a group label.' ); + } + + /** + * Test that an owned group subscription contributes its group name. + */ + public function test_group_label_for_owned_group_subscription() { + $product_id = $this->create_mock_product( 517, 'Owner Plan' ); + $sub = $this->create_subscription( self::$user_id, [ $product_id ] ); + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', 'yes' ); + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'name', 'Owner Group' ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Owner Group Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'], 'Owner should have access via their own subscription.' ); + $this->assertEquals( 'Owner Plan', $result['Content_Access_Source'], 'Owner should see the product name as source.' ); + $this->assertEquals( 'Owner Group', $result['Content_Access_Group'], 'Owner should see the group name in Content_Access_Group.' ); + } + + /** + * Test that the same group subscription is not double-counted when the user is both owner and member. + */ + public function test_group_label_dedupes_owner_who_is_also_member() { + $product_id = $this->create_mock_product( 518, 'Dual Role Plan' ); + $sub = $this->create_subscription( self::$user_id, [ $product_id ] ); + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', 'yes' ); + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'name', 'Dual Role Group' ); + // Also list the owner as a member (edge case: should not produce a duplicate name). + add_user_meta( self::$user_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY, $sub->get_id() ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Dual Role Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Dual Role Group', $result['Content_Access_Group'], 'Group name should appear once even when the user is both owner and member.' ); + } + + /** + * Test that owned and member group subscriptions both surface, sorted. + */ + public function test_group_label_combines_owned_and_member_groups() { + $pid_owned = $this->create_mock_product( 519, 'Owner Plan' ); + $pid_member = $this->create_mock_product( 520, 'Member Plan' ); + + $owned_sub = $this->create_subscription( self::$user_id, [ $pid_owned ] ); + $owned_sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', 'yes' ); + $owned_sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'name', 'Owned Group' ); + + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $pid_member ], 'Member Group' ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $pid_owned, $pid_member ], + ], + ], + ]; + $this->create_gate_with_rules( 'Combined Group Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Member Group, Owned Group', $result['Content_Access_Group'], 'Both owned and member group names should appear, sorted naturally.' ); + } + + /** + * Test that an institution match produces the institution name. + */ + public function test_group_label_for_institution() { + $inst_id = $this->create_institution( 'Test University', [ 'email_domain' => 'example.com' ] ); + + $rules = [ + [ + [ + 'slug' => 'institution', + 'value' => [ $inst_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Institution Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'] ); + $this->assertEquals( 'Test University', $result['Content_Access_Group'], 'Institution name should appear in Content_Access_Group.' ); + } + + /** + * Test that matching multiple institutions produces sorted names. + */ + public function test_group_label_with_multiple_institutions() { + $inst_a = $this->create_institution( 'Zeta College', [ 'email_domain' => 'example.com' ] ); + $inst_b = $this->create_institution( 'Alpha University', [ 'email_domain' => 'example.com' ] ); + + $rules = [ + [ + [ + 'slug' => 'institution', + 'value' => [ $inst_a, $inst_b ], + ], + ], + ]; + $this->create_gate_with_rules( 'Multi Institution Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Alpha University, Zeta College', $result['Content_Access_Group'], 'Institution names should appear sorted naturally.' ); + } + + /** + * Test that an email_domain rule produces no group label. + */ + public function test_group_label_empty_for_email_domain() { + $rules = [ + [ + [ + 'slug' => 'email_domain', + 'value' => 'example.com', + ], + ], + ]; + $this->create_gate_with_rules( 'Domain Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'] ); + $this->assertEmpty( $result['Content_Access_Group'], 'Email domain rule should not contribute a group label.' ); + } + + /** + * Empty-value subscription rule ("any subscription") with a group-membership user. + * The group name should still surface in Content_Access_Group. + */ + public function test_group_label_for_empty_rule_value_with_group_member() { + $product_id = $this->create_mock_product( 531, 'Any Group Plan' ); + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $product_id ], 'Any Group' ); + + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [], + ], + ], + ]; + $this->create_gate_with_rules( 'Any Group Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'] ); + $this->assertEquals( 'Any Group', $result['Content_Access_Group'], 'Group name should surface even when the rule value is empty.' ); + } + + /** + * Test that a user matching both a group subscription and an institution gets both names. + */ + public function test_group_label_for_group_subscription_and_institution() { + $product_id = $this->create_mock_product( 516, 'Combo Plan' ); + $this->create_group_subscription_with_member( self::$owner_id, self::$user_id, [ $product_id ], 'ACME Corp' ); + $inst_id = $this->create_institution( 'State University', [ 'email_domain' => 'example.com' ] ); + + // One gate, one rule group (AND logic) — both rules must pass. + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + [ + 'slug' => 'institution', + 'value' => [ $inst_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'Group + Institution Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'], 'User passing both rules should have access.' ); + $this->assertEquals( 'ACME Corp, State University', $result['Content_Access_Group'], 'Both group subscription and institution names should appear, sorted.' ); + } + + /** + * Test that a malformed institution rule value (non-array) does not fatal + * and contributes no group label. + * + * Institution::evaluate() treats a non-array $value as "matches everyone," + * so the rule passes — but there's no specific institution to attribute, so + * Content_Access_Group must come back empty (and crucially: no TypeError on + * a `foreach` over a non-iterable). + * + * @dataProvider malformed_institution_value_provider + * + * @param mixed $value Malformed rule value. + */ + public function test_group_label_empty_for_malformed_institution_rule( $value ) { + // An institution must exist so the rule evaluation has something to consider. + $this->create_institution( 'Test University', [ 'email_domain' => 'example.com' ] ); + + $rules = [ + [ + [ + 'slug' => 'institution', + 'value' => $value, + ], + ], + ]; + $this->create_gate_with_rules( 'Malformed Institution Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'Yes', $result['Content_Access'], 'Malformed institution rule matches everyone per Institution::evaluate().' ); + $this->assertEmpty( $result['Content_Access_Group'], 'Malformed institution rule should yield no group label.' ); + } + + /** + * Data provider for malformed institution rule values. + */ + public function malformed_institution_value_provider() { + return [ + 'empty string' => [ '' ], + 'empty array' => [ [] ], + 'null' => [ null ], + 'scalar int' => [ 5 ], + 'scalar str' => [ '5' ], + ]; + } + + /** + * Test that no access yields an empty Content_Access_Group. + */ + public function test_group_label_empty_when_access_denied() { + $product_id = $this->create_mock_product( 515, 'Some Plan' ); + // User does not own and is not a member of any subscription. + $rules = [ + [ + [ + 'slug' => 'subscription', + 'value' => [ $product_id ], + ], + ], + ]; + $this->create_gate_with_rules( 'No Access Gate', $rules ); + + $result = $this->get_metadata_for_user( self::$user_id ); + + $this->assertEquals( 'No', $result['Content_Access'] ); + $this->assertEmpty( $result['Content_Access_Group'], 'Denied access should yield an empty group label.' ); + } } diff --git a/tests/unit-tests/content-gate/class-institution.php b/tests/unit-tests/content-gate/class-institution.php index 0e33a14405..dcbbc79198 100644 --- a/tests/unit-tests/content-gate/class-institution.php +++ b/tests/unit-tests/content-gate/class-institution.php @@ -222,6 +222,10 @@ public function test_ip_range_match() { $this->assertIsInt( $inst_id ); $this->post_ids[] = $inst_id; $reader_id = $this->create_reader( 'reader@test.com' ); + // Log the reader in: user_matches_institution only treats requests as + // uncached when $user_id matches the current user (or a cache-bypass + // cookie is present). Without this, the IP check is skipped. + wp_set_current_user( $reader_id ); delete_transient( Institution::TRANSIENT_KEY ); @@ -241,6 +245,56 @@ public function test_ip_range_match() { unset( $_SERVER['REMOTE_ADDR'] ); $this->assertFalse( Institution::evaluate( $reader_id, [ $inst_id ] ) ); + wp_set_current_user( 0 ); + + // phpcs:enable + } + + /** + * Test that the IP check does not leak the current visitor's IP to a + * different user (e.g., when ESP sync evaluates an institution rule for + * user A while admin/cron/webhook user B is the active request). + * + * Regression test: prior to the tightening of $is_uncached, any non-zero + * $user_id was treated as "uncached," so the visitor's IP was checked + * against the rule regardless of which user was being evaluated. + */ + public function test_ip_range_does_not_leak_across_users() { + $inst_id = Institution::create( + 'Cross-User IP Institution', + '', + [ 'ip_range' => '10.0.0.0/8' ] + ); + $this->assertIsInt( $inst_id ); + $this->post_ids[] = $inst_id; + $evaluated_reader = $this->create_reader( 'evaluated@test.com' ); + $current_visitor = $this->create_reader( 'visitor@test.com' ); + + delete_transient( Institution::TRANSIENT_KEY ); + + // phpcs:disable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE + + // The current visitor (user B) is logged in, with a matching IP. We + // then ask whether user A (a different user) matches the institution — + // the IP check must NOT run for user A. + wp_set_current_user( $current_visitor ); + $_SERVER['REMOTE_ADDR'] = '10.1.2.3'; + unset( $_COOKIE[ \Newspack\Content_Gate\IP_Access_Rule::COOKIE_NAME ] ); + + $this->assertFalse( + Institution::evaluate( $evaluated_reader, [ $inst_id ] ), + 'IP-based institution match should not transfer the current visitor\'s IP to a different user.' + ); + + // Sanity check: the current visitor themselves *does* match (same IP, same user). + $this->assertTrue( + Institution::evaluate( $current_visitor, [ $inst_id ] ), + 'The current visitor with a matching IP should still match.' + ); + + wp_set_current_user( 0 ); + unset( $_SERVER['REMOTE_ADDR'] ); + // phpcs:enable } diff --git a/tests/unit-tests/content-gate/group-subscriptions.php b/tests/unit-tests/content-gate/group-subscriptions.php index c6e179bbb0..3c7937fb07 100644 --- a/tests/unit-tests/content-gate/group-subscriptions.php +++ b/tests/unit-tests/content-gate/group-subscriptions.php @@ -429,6 +429,66 @@ public function test_get_group_subscriptions_for_user_with_no_memberships() { $this->assertEmpty( $result ); } + /** + * Test get_group_subscriptions_for_user() filters out user meta entries that + * point to subscriptions which no longer exist (e.g. were deleted), so the + * returned array never contains false/null items. + */ + public function test_get_group_subscriptions_for_user_filters_missing_subscriptions() { + $owner_id = $this->create_reader_user(); + $member_id = $this->create_reader_user(); + $group_sub = $this->create_group_subscription( $owner_id ); + + // Legitimate membership. + add_user_meta( $member_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY, $group_sub->get_id() ); + // Stale meta pointing to a subscription ID that does not exist in the database. + add_user_meta( $member_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY, 99999 ); + + $subscriptions = Group_Subscription::get_group_subscriptions_for_user( $member_id ); + + $this->assertCount( 1, $subscriptions, 'Stale subscription IDs should be filtered out' ); + foreach ( $subscriptions as $subscription ) { + $this->assertNotEmpty( $subscription, 'Returned items must not be false/null' ); + $this->assertInstanceOf( \WC_Subscription::class, $subscription ); + } + + // IDs-only mode should also exclude the missing subscription. + $ids = Group_Subscription::get_group_subscriptions_for_user( $member_id, true ); + $this->assertEquals( [ $group_sub->get_id() ], array_values( $ids ) ); + $this->assertNotContains( 99999, $ids, 'Stale subscription IDs should not appear in IDs-only mode' ); + } + + /** + * Test get_group_subscriptions_for_user() returns only group subscriptions, + * filtering out any meta entries that point to non-group subscriptions. + */ + public function test_get_group_subscriptions_for_user_filters_non_group_subscriptions() { + $owner_id = $this->create_reader_user(); + $member_id = $this->create_reader_user(); + $group_sub = $this->create_group_subscription( $owner_id ); + $regular_sub = $this->create_regular_subscription( $owner_id ); + + // Membership in a real group subscription. + add_user_meta( $member_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY, $group_sub->get_id() ); + // Meta pointing to a regular (non-group) subscription that should be filtered out. + add_user_meta( $member_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY, $regular_sub->get_id() ); + + $subscriptions = Group_Subscription::get_group_subscriptions_for_user( $member_id ); + + $this->assertCount( 1, $subscriptions, 'Non-group subscriptions should be filtered out' ); + foreach ( $subscriptions as $subscription ) { + $this->assertTrue( + Group_Subscription::is_group_subscription( $subscription ), + 'Every returned subscription must be a group subscription' + ); + } + + // IDs-only mode should also exclude the regular subscription. + $ids = Group_Subscription::get_group_subscriptions_for_user( $member_id, true ); + $this->assertEquals( [ $group_sub->get_id() ], array_values( $ids ) ); + $this->assertNotContains( $regular_sub->get_id(), $ids, 'Regular subscription IDs should not appear in IDs-only mode' ); + } + // ------------------------------------------------------------------------- // Group_Subscription_Settings name tests // ------------------------------------------------------------------------- diff --git a/tests/unit-tests/plugins/google-site-kit.php b/tests/unit-tests/plugins/google-site-kit.php new file mode 100644 index 0000000000..d9de32d569 --- /dev/null +++ b/tests/unit-tests/plugins/google-site-kit.php @@ -0,0 +1,278 @@ +factory->user->create( + [ + 'role' => 'subscriber', + 'user_email' => 'reader@example.com', + ] + ); + Reader_Activation::set_reader_verified( self::$user_id ); + + self::$owner_id = $this->factory->user->create( + [ + 'role' => 'subscriber', + 'user_email' => 'owner@example.com', + ] + ); + + // Make the reader the current user so get_custom_event_parameters() picks them up. + wp_set_current_user( self::$user_id ); + } + + /** + * Clean up after each test. + */ + public function tear_down() { + delete_user_meta( self::$user_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY ); + + // Delete any institution posts created during the test so they don't leak + // into later tests (Institution::create() inserts real posts not tracked by $this->factory). + foreach ( $this->institution_ids as $post_id ) { + wp_delete_post( $post_id, true ); + } + $this->institution_ids = []; + delete_transient( Institution::TRANSIENT_KEY ); + + Group_Subscription::reset_cache(); + Institution::reset_matching_cache(); + wp_set_current_user( 0 ); + parent::tear_down(); + } + + /** + * Helper: create a group subscription owned by $owner_id (with the user as a member if $member_id is given). + * + * @param int $owner_id Owner user ID. + * @param int|null $member_id Member user ID, or null for none. + * @param int $product_id Product ID. + * @param string $name Group name. + * @param string $status Subscription status. + * @return \WC_Subscription + */ + private function create_group_subscription( $owner_id, $member_id, $product_id, $name, $status = 'active' ) { + $sub = wcs_create_subscription( + [ + 'customer_id' => $owner_id, + 'status' => $status, + 'billing_period' => 'month', + 'products' => [ $product_id ], + ] + ); + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', 'yes' ); + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'name', $name ); + if ( $member_id ) { + add_user_meta( $member_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY, $sub->get_id() ); + } + return $sub; + } + + /** + * Helper: create an institution post and refresh the cache. + * + * @param string $title Institution title. + * @param array $rules Institution rules. + * @return int Institution post ID. + */ + private function create_institution( $title, $rules ) { + $id = Institution::create( $title, '', $rules ); + $this->institution_ids[] = $id; + Institution::invalidate_cache(); + return $id; + } + + /** + * With no group subscriptions or institutions, `group` defaults to "none". + */ + public function test_group_defaults_to_none() { + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertArrayHasKey( 'group', $params ); + $this->assertEquals( 'none', $params['group'] ); + } + + /** + * Owned group subscription contributes its anonymized ID label. + */ + public function test_group_includes_owned_group_subscription() { + $sub = $this->create_group_subscription( self::$user_id, null, 600, 'Owner Group' ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertEquals( 'Group ' . $sub->get_id(), $params['group'] ); + } + + /** + * Group membership (non-owner) contributes the anonymized ID label. + */ + public function test_group_includes_member_group_subscription() { + $sub = $this->create_group_subscription( self::$owner_id, self::$user_id, 601, 'Member Group' ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertEquals( 'Group ' . $sub->get_id(), $params['group'] ); + } + + /** + * Matching institution (via verified email domain) contributes the anonymized ID label. + */ + public function test_group_includes_matching_institution() { + $inst_id = $this->create_institution( 'Test University', [ 'email_domain' => 'example.com' ] ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertEquals( 'Institution ' . $inst_id, $params['group'] ); + } + + /** + * Group sub names / institution titles never appear in the GA4 `group` value — + * confirms the anonymized-ID contract that exists to keep PII out of GA4. + */ + public function test_group_never_emits_publisher_facing_names() { + $this->create_group_subscription( self::$user_id, null, 610, 'Acme Corp Engineering Team' ); + $this->create_group_subscription( self::$owner_id, self::$user_id, 611, "John Doe's Group" ); + $this->create_institution( 'State University', [ 'email_domain' => 'example.com' ] ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertStringNotContainsString( 'Acme Corp', $params['group'] ); + $this->assertStringNotContainsString( 'John Doe', $params['group'] ); + $this->assertStringNotContainsString( 'State University', $params['group'] ); + } + + /** + * Multiple group subscriptions and an institution all surface, sorted naturally. + */ + public function test_group_combines_owned_member_and_institution_sorted() { + $owned = $this->create_group_subscription( self::$user_id, null, 602, 'Zeta Group' ); + $member = $this->create_group_subscription( self::$owner_id, self::$user_id, 603, 'Beta Group' ); + $inst_id = $this->create_institution( 'Alpha University', [ 'email_domain' => 'example.com' ] ); + $expected = [ + 'Group ' . $owned->get_id(), + 'Group ' . $member->get_id(), + 'Institution ' . $inst_id, + ]; + sort( $expected, SORT_NATURAL | SORT_FLAG_CASE ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertEquals( implode( ', ', $expected ), $params['group'] ); + } + + /** + * Inactive group subscriptions do not contribute a label. + */ + public function test_group_excludes_cancelled_group_subscription() { + $active = $this->create_group_subscription( self::$user_id, null, 604, 'Active Owned', 'active' ); + $cancelled = $this->create_group_subscription( self::$owner_id, self::$user_id, 605, 'Cancelled Member', 'cancelled' ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertEquals( 'Group ' . $active->get_id(), $params['group'] ); + $this->assertStringNotContainsString( (string) $cancelled->get_id(), $params['group'] ); + } + + /** + * Anonymous (non-logged-in) requests get `group` = "none". + */ + public function test_group_for_anonymous_user_is_none() { + wp_set_current_user( 0 ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertEquals( 'none', $params['group'] ); + } + + /** + * Owner who is also listed as a member is not double-counted. + */ + public function test_group_dedupes_owner_who_is_also_member() { + $sub = $this->create_group_subscription( self::$user_id, null, 606, 'Self Group' ); + // Add the owner as a member too. + add_user_meta( self::$user_id, Group_Subscription::GROUP_SUBSCRIPTION_USER_META_KEY, $sub->get_id() ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertEquals( 'Group ' . $sub->get_id(), $params['group'] ); + } + + /** + * Two distinct group subscriptions sharing the same display name appear as two + * distinct anonymized labels — confirms each subscription has its own identity + * in the GA4 payload, regardless of name collisions. + */ + public function test_group_distinct_subs_with_same_name_get_distinct_ids() { + $sub_a = $this->create_group_subscription( self::$user_id, null, 607, 'Shared Name' ); + $sub_b = $this->create_group_subscription( self::$owner_id, self::$user_id, 608, 'Shared Name' ); + $expected = [ 'Group ' . $sub_a->get_id(), 'Group ' . $sub_b->get_id() ]; + sort( $expected, SORT_NATURAL | SORT_FLAG_CASE ); + + $params = GoogleSiteKit::get_custom_event_parameters(); + + $this->assertEquals( implode( ', ', $expected ), $params['group'] ); + } +}