diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index 6002adf8f..130bae2cb 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -177,7 +177,10 @@ function hc_custom_bpeo_group_event_meta_cap( $caps, $cap, $user_id, $args ) { } switch ( $cap ) { - case 'read_private_events': + // 'read_private_events' is deliberately not handled here: it is a + // primitive cap with no event context, so group membership cannot be + // evaluated for it. Mapping it to 'exist' (as this once did) handed + // every visitor the ability to list all private events. case 'read_event': // we've already parsed this logic in bpeo_map_basic_meta_caps(). if ( 'exist' === $caps[0] ) { @@ -549,3 +552,273 @@ function hc_custom_bpeo_activity_action_format_for_groups( $action, $activity ) + +/** Group calendar scoping *****************************************************/ + +/** + * Resolve the group a calendar request is being built for. + * + * @param array|null $request Request vars; defaults to $_GET. + * @return int Group ID, or 0 when the request is not for a group. + */ +function hc_custom_bpeo_get_calendar_group_id( $request = null ) { + // On the group page itself BuddyPress knows the group. + if ( function_exists( 'bp_is_group' ) && bp_is_group() ) { + return (int) bp_get_current_group_id(); + } + + // Inside the eventorganiser-fullcal admin-ajax request BuddyPress has no + // group context (it skips URI parsing for AJAX), so the calendar passes + // the group along explicitly. + if ( null === $request ) { + $request = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + if ( ! isset( $request['bp_group'] ) || ! is_scalar( $request['bp_group'] ) ) { + return 0; + } + + $bp_group = (string) $request['bp_group']; + if ( '' === $bp_group || ! ctype_digit( $bp_group ) ) { + return 0; + } + + return (int) $bp_group; +} + +/** + * Restrict the Event Organiser calendar query to the current group's events. + * + * @param array $query Query vars as set up by EO. + * @return array + */ +function hc_custom_bpeo_filter_calendar_query_for_group( $query ) { + $group_id = hc_custom_bpeo_get_calendar_group_id(); + + if ( ! $group_id ) { + return $query; + } + + // The admin-ajax endpoint has none of the group page's access control, so + // only honour the scope for groups the caller may see. A denied request + // gets an empty scope, which bpeo's pre_get_posts handler turns into + // post__in => array( 0 ), i.e. no events, rather than the unscoped calendar. + if ( ! hc_custom_bpeo_user_can_view_group_calendar( get_current_user_id(), $group_id ) ) { + $query['bp_group'] = array(); + return $query; + } + + // Consumed by bpeo's pre_get_posts handling of 'bp_group', which turns it + // into a bpeo_event_group tax query (and admits private events, which is + // how events in non-public groups are stored). + $query['bp_group'] = $group_id; + + // Members of the group (and moderators) may see its private events, so for + // them do not let EO's capability-based 'readable' restriction hide those + // on top of the group check. Anyone else viewing a public group keeps the + // role/author check: an event connected to both this public group and a + // private group is a private post and must not leak through here. + if ( current_user_can( 'bp_moderate' ) || groups_is_user_member( get_current_user_id(), $group_id ) ) { + $query['perm'] = ''; + } else { + $query['perm'] = 'readable'; + } + + return $query; +} + +/** + * Whether a user may view a group's calendar. + * + * Mirrors the visibility rule bp-event-organiser applies when listing an + * event's connected groups: public groups are open to all; private and hidden + * groups to their members and to community moderators. + * + * @param int $user_id User ID (0 for anonymous). + * @param int $group_id Group ID. + * @return bool + */ +function hc_custom_bpeo_user_can_view_group_calendar( $user_id, $group_id ) { + $group = groups_get_group( array( 'group_id' => (int) $group_id ) ); + + if ( empty( $group->id ) ) { + return false; + } + + if ( 'public' === $group->status ) { + return true; + } + + if ( current_user_can( 'bp_moderate' ) ) { + return true; + } + + return (bool) groups_is_user_member( (int) $user_id, (int) $group_id ); +} +// After bp-event-organiser's own filter (priority 10), which only works when +// bp_is_group() is true and so is a no-op inside admin-ajax. +add_filter( 'eventorganiser_fullcalendar_query', 'hc_custom_bpeo_filter_calendar_query_for_group', 20 ); + +/** + * Load the script that sends the calendar's group along with its AJAX requests. + * + * Event Organiser enqueues 'eo_front' from wp_footer only when a calendar is on + * the page, so this runs just before footer scripts print and piggybacks on it. + * Hooked to wp_print_footer_scripts rather than wp_footer because the embedded + * group calendar (?embedded=true) strips wp_footer actions. + */ +function hc_custom_bpeo_enqueue_group_calendar_script() { + if ( ! wp_script_is( 'eo_front', 'enqueued' ) ) { + return; + } + + $js_path = 'includes/js/bpeo-group-calendar.js'; + + wp_enqueue_script( + 'hc-custom-bpeo-group-calendar', + plugins_url( $js_path, __DIR__ ), + array( 'eo_front' ), + filemtime( trailingslashit( plugin_dir_path( __DIR__ ) ) . $js_path ), + true + ); +} +add_action( 'wp_print_footer_scripts', 'hc_custom_bpeo_enqueue_group_calendar_script', 5 ); + +/** + * Point calendar event links at the group's rendering of the event. + * + * @param string $link Current event permalink. + * @param int $event_id Event post ID. + * @param int $occurrence_id Occurrence ID. + * @return string + */ +function hc_custom_bpeo_filter_calendar_event_link_for_group( $link, $event_id = 0, $occurrence_id = 0 ) { + $group_id = hc_custom_bpeo_get_calendar_group_id(); + $event_id = (int) $event_id; + + if ( ! $group_id || ! $event_id ) { + return $link; + } + + // Only events actually connected to this group have a rendering under it. + $event_groups = array_map( 'intval', (array) bpeo_get_event_groups( $event_id ) ); + if ( ! in_array( $group_id, $event_groups, true ) ) { + return $link; + } + + $event = get_post( $event_id ); + if ( ! $event || empty( $event->post_name ) ) { + return $link; + } + + return trailingslashit( bpeo_get_group_permalink( $group_id ) . $event->post_name ); +} +// After bp-event-organiser's own filter (priority 10), which only works when +// bp_is_group() is true and so is a no-op inside admin-ajax. +add_filter( 'eventorganiser_calendar_event_link', 'hc_custom_bpeo_filter_calendar_event_link_for_group', 20, 3 ); + +/** Sitewide calendar: members' private group events ***************************/ + +/** + * IDs of the groups a user belongs to. + * + * @param int $user_id User ID. + * @return int[] + */ +function hc_custom_bpeo_get_member_group_ids( $user_id ) { + $user_id = (int) $user_id; + if ( ! $user_id ) { + return array(); + } + + $user_groups = groups_get_user_groups( $user_id ); + if ( empty( $user_groups['groups'] ) ) { + return array(); + } + + return array_values( array_filter( array_map( 'intval', (array) $user_groups['groups'] ) ) ); +} + +/** + * Let a logged-in member see their private groups' events on the unscoped + * (sitewide) calendar. + * + * @param array $query Query vars as set up by EO. + * @return array + */ +function hc_custom_bpeo_filter_calendar_query_for_member_groups( $query ) { + // Group-scoped (including denied) and member calendars manage their own + // visibility. + if ( isset( $query['bp_group'] ) || isset( $query['bp_displayed_user_id'] ) ) { + return $query; + } + + // Callers whose role grants read_private_events already see every private + // event through EO's 'readable' query; the membership-based restriction + // below would only take events away from them. + if ( current_user_can( 'read_private_events' ) ) { + return $query; + } + + $group_ids = hc_custom_bpeo_get_member_group_ids( get_current_user_id() ); + if ( empty( $group_ids ) ) { + return $query; + } + + // EO's 'readable' perm would limit private posts to the caller's own, + // which, now that read_private_events is no longer handed to everyone, + // would hide their groups' events. Admit private posts and let the + // posts_where restriction confine them to the caller's groups, inside + // the main query so it stays bounded by the calendar's date range. + $query['hc_bpeo_member_group_ids'] = $group_ids; + $query['perm'] = ''; + $query['post_status'] = array_values( array_unique( array_merge( (array) ( $query['post_status'] ?? array( 'publish' ) ), array( 'private' ) ) ) ); + + return $query; +} +add_filter( 'eventorganiser_fullcalendar_query', 'hc_custom_bpeo_filter_calendar_query_for_member_groups', 30 ); + +/** + * Keep private events outside the caller's groups out of the query. + * + * Expressed as a subquery on the bpeo_event_group term relationships rather + * than a materialised ID list, so the cost follows the main query's date + * range, not the groups' full event history. + * + * @param string $where SQL WHERE clause. + * @param WP_Query $wp_query The query. + * @return string + */ +function hc_custom_bpeo_restrict_private_events_where( $where, $wp_query ) { + $group_ids = $wp_query->get( 'hc_bpeo_member_group_ids' ); + if ( empty( $group_ids ) ) { + return $where; + } + + $group_ids = array_values( array_filter( array_map( 'intval', (array) $group_ids ) ) ); + if ( empty( $group_ids ) ) { + return $where; + } + + global $wpdb; + $posts = $wpdb->posts; + $user_id = (int) get_current_user_id(); + + // Term names are 'group_' and built from integers only, so they need + // no further escaping. + $term_names = array(); + foreach ( $group_ids as $group_id ) { + $term_names[] = "'group_{$group_id}'"; + } + $term_list = implode( ',', $term_names ); + + $in_member_groups = "SELECT tr.object_id FROM {$wpdb->term_relationships} tr" + . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" + . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" + . " WHERE tt.taxonomy = 'bpeo_event_group' AND t.name IN ({$term_list})"; + + $where .= " AND ( {$posts}.post_status <> 'private' OR {$posts}.post_author = {$user_id} OR {$posts}.ID IN ({$in_member_groups}) )"; + + return $where; +} +add_filter( 'posts_where', 'hc_custom_bpeo_restrict_private_events_where', 20, 2 ); diff --git a/plugins/hc-custom/includes/js/bpeo-group-calendar.js b/plugins/hc-custom/includes/js/bpeo-group-calendar.js new file mode 100644 index 000000000..151f5eef5 --- /dev/null +++ b/plugins/hc-custom/includes/js/bpeo-group-calendar.js @@ -0,0 +1,49 @@ +/** + * Send the group a calendar belongs to along with its event requests. + * + * Event Organiser fetches calendar events via admin-ajax, where BuddyPress has + * no group context. bp-event-organiser already places 'bp_group' in the + * localized calendar settings (eventorganiser.calendars); this forwards it on + * the request so the server can scope the results to the group. + */ +( function ( window ) { + 'use strict'; + + /** + * Map a calendar element ID ("#eo_fullcalendar_N") to its index in + * eventorganiser.calendars. + */ + function calendarIndexFromId( id ) { + var match = /eo_fullcalendar_(\d+)/.exec( String( id || '' ) ); + return match ? parseInt( match[ 1 ], 10 ) - 1 : -1; + } + + function addGroupToRequest( request, start, end, timezone, options ) { + var calendars = window.eventorganiser && window.eventorganiser.calendars; + var index = calendarIndexFromId( options && options.id ); + var calendar = calendars && index >= 0 ? calendars[ index ] : null; + + if ( calendar && calendar.bp_group ) { + request.bp_group = calendar.bp_group; + } + + return request; + } + + var hooks = window.wp && window.wp.hooks; + + if ( ! hooks || typeof hooks.addFilter !== 'function' ) { + return; + } + + // Event Organiser ships a legacy hooks shim (event-manager.js) with the + // signature addFilter( name, callback, priority ), but defers to WordPress + // core's @wordpress/hooks when that is already on the page, which takes + // addFilter( name, namespace, callback, priority ). Core exposes hasFilter; + // the shim does not. + if ( typeof hooks.hasFilter === 'function' ) { + hooks.addFilter( 'eventorganiser.fullcalendar_request', 'hc-custom/bpeo-group-calendar', addGroupToRequest, 10 ); + } else { + hooks.addFilter( 'eventorganiser.fullcalendar_request', addGroupToRequest, 10 ); + } +} )( window ); diff --git a/tests/unit/BpeoGroupCalendarLinksTest.php b/tests/unit/BpeoGroupCalendarLinksTest.php new file mode 100644 index 000000000..0cc862a5e --- /dev/null +++ b/tests/unit/BpeoGroupCalendarLinksTest.php @@ -0,0 +1,94 @@ +/events//) rather than the canonical event page. This has to + * work inside the admin-ajax request that builds the calendar payload, where + * BuddyPress has no group context and the group arrives as a request param. + */ + +use PHPUnit\Framework\TestCase; + +class BpeoGroupCalendarLinksTest extends TestCase { + + const CANONICAL = 'https://example.org/events/event/zoo-visit/'; + + protected function setUp(): void { + $GLOBALS['_hc_mock'] = array(); + $_GET = array(); + + $event = new stdClass(); + $event->ID = 7; + $event->post_type = 'event'; + $event->post_name = 'zoo-visit'; + + $GLOBALS['_hc_mock']['posts'] = array( 7 => $event ); + $GLOBALS['_hc_mock']['event_groups'] = array( 7 => array( 50 ) ); + $GLOBALS['_hc_mock']['events_group_permalinks'] = array( + 50 => 'https://example.org/groups/mpes-grp/events/', + 60 => 'https://example.org/groups/other-grp/events/', + ); + } + + protected function tearDown(): void { + $_GET = array(); + } + + public function test_link_rewritten_to_group_event_url_in_ajax_context() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $link = hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ); + + $this->assertSame( 'https://example.org/groups/mpes-grp/events/zoo-visit/', $link ); + } + + public function test_link_rewritten_to_group_event_url_in_page_context() { + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + + $link = hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7 ); + + $this->assertSame( 'https://example.org/groups/mpes-grp/events/zoo-visit/', $link ); + } + + public function test_link_uses_the_requested_group_not_another_connected_group() { + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['event_groups'] = array( 7 => array( 50, 60 ) ); + $_GET['bp_group'] = '60'; + + $link = hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ); + + $this->assertSame( 'https://example.org/groups/other-grp/events/zoo-visit/', $link ); + } + + public function test_link_unchanged_outside_group_context() { + $GLOBALS['_hc_mock']['is_group'] = false; + + $this->assertSame( self::CANONICAL, hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ) ); + } + + public function test_link_unchanged_when_event_not_connected_to_requested_group() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '60'; + + $this->assertSame( self::CANONICAL, hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ) ); + } + + public function test_link_unchanged_when_event_cannot_be_loaded() { + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['posts'] = array(); + $_GET['bp_group'] = '50'; + + $this->assertSame( self::CANONICAL, hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ) ); + } + + public function test_link_unchanged_when_no_event_id_given() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $this->assertSame( self::CANONICAL, hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL ) ); + } +} diff --git a/tests/unit/BpeoGroupCalendarScopeTest.php b/tests/unit/BpeoGroupCalendarScopeTest.php new file mode 100644 index 000000000..977cabe55 --- /dev/null +++ b/tests/unit/BpeoGroupCalendarScopeTest.php @@ -0,0 +1,324 @@ + $group_id, + 'status' => $status, + ); + } + + protected function tearDown(): void { + $_GET = array(); + } + + // ----------------------------------------------------------------------- + // Resolving the group a calendar request is for. + // ----------------------------------------------------------------------- + + public function test_group_resolved_from_page_context_when_viewing_a_group() { + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + + $this->assertSame( 50, hc_custom_bpeo_get_calendar_group_id() ); + } + + public function test_group_resolved_from_request_outside_group_context() { + // admin-ajax: no BP group context, but the calendar passes bp_group. + $GLOBALS['_hc_mock']['is_group'] = false; + + $this->assertSame( 50, hc_custom_bpeo_get_calendar_group_id( array( 'bp_group' => '50' ) ) ); + } + + public function test_group_resolved_from_superglobal_when_no_request_given() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '72'; + + $this->assertSame( 72, hc_custom_bpeo_get_calendar_group_id() ); + } + + public function test_page_context_wins_over_request_parameter() { + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + + $this->assertSame( 50, hc_custom_bpeo_get_calendar_group_id( array( 'bp_group' => '99' ) ) ); + } + + public function test_no_group_when_neither_context_nor_request_identify_one() { + $GLOBALS['_hc_mock']['is_group'] = false; + + $this->assertSame( 0, hc_custom_bpeo_get_calendar_group_id( array() ) ); + } + + /** + * @dataProvider invalidGroupParameters + */ + public function test_invalid_request_parameter_yields_no_group( $value ) { + $GLOBALS['_hc_mock']['is_group'] = false; + + $this->assertSame( 0, hc_custom_bpeo_get_calendar_group_id( array( 'bp_group' => $value ) ) ); + } + + public function invalidGroupParameters() { + return array( + 'non-numeric' => array( 'mpes-grp' ), + 'negative' => array( '-5' ), + 'zero' => array( '0' ), + 'empty string' => array( '' ), + 'array' => array( array( '50' ) ), + 'sql-ish' => array( '50 OR 1=1' ), + ); + } + + // ----------------------------------------------------------------------- + // The Event Organiser calendar query. + // ----------------------------------------------------------------------- + + public function test_calendar_query_scoped_to_group_from_request() { + $this->setUpGroup( 50 ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'showpastevents' => true ) ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_calendar_query_scoped_to_group_from_page_context() { + $this->setUpGroup( 50 ); + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_calendar_query_preserves_existing_arguments() { + $this->setUpGroup( 50 ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $in = array( + 'event_start_before' => '2026-11-01', + 'event_end_after' => '2026-09-28', + 'post_status' => array( 'publish', 'private' ), + ); + $out = hc_custom_bpeo_filter_calendar_query_for_group( $in ); + + foreach ( $in as $key => $value ) { + $this->assertSame( $value, $out[ $key ] ); + } + } + + public function test_calendar_query_untouched_outside_any_group_context() { + $GLOBALS['_hc_mock']['is_group'] = false; + + $in = array( 'event_start_before' => '2026-11-01' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_group( $in ) ); + } + + public function test_calendar_query_untouched_for_invalid_request_group() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = 'not-a-group'; + + $in = array( 'event_start_before' => '2026-11-01' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_group( $in ) ); + } + + // ----------------------------------------------------------------------- + // Group access: the AJAX endpoint has none of the group page's gating, so + // the scope must only be honoured for groups the caller may see. Denied + // requests yield an empty scope (no events), never the unscoped calendar. + // ----------------------------------------------------------------------- + + public function test_public_group_scoped_for_anonymous_caller() { + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_private_group_denied_for_anonymous_caller() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( array(), $query['bp_group'] ); + } + + public function test_private_group_denied_for_logged_in_non_member() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:60' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( array(), $query['bp_group'] ); + } + + public function test_private_group_scoped_for_member() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:50' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_hidden_group_denied_for_non_member_and_scoped_for_member() { + $this->setUpGroup( 50, 'hidden' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $_GET['bp_group'] = '50'; + + $GLOBALS['_hc_mock']['group_members'] = array(); + $this->assertSame( array(), hc_custom_bpeo_filter_calendar_query_for_group( array() )['bp_group'] ); + + $GLOBALS['_hc_mock']['group_members'] = array( '7:50' ); + $this->assertSame( 50, hc_custom_bpeo_filter_calendar_query_for_group( array() )['bp_group'] ); + } + + public function test_private_group_scoped_for_moderator_who_is_not_a_member() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['user_can'] = array( 'bp_moderate' => true ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_nonexistent_group_yields_empty_scope() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '999'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( array(), $query['bp_group'] ); + } + + public function test_access_check_also_applies_in_page_context() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( array(), $query['bp_group'] ); + } + + public function test_granted_scope_does_not_filter_private_events_by_capability() { + // Members of a private group must see its (private) events; access has + // already been verified against the group, so EO's capability-based + // 'readable' restriction must not apply on top. + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:50' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( '', $query['perm'] ); + } + + public function test_anonymous_request_to_public_group_keeps_capability_restriction() { + // A public group's calendar is open to all, but an event connected to + // both this public group and a private group is a private post. Only + // members of the requested group (or moderators) may bypass EO's + // capability check; anonymous visitors must keep it. + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( 50, $query['bp_group'] ); + $this->assertSame( 'readable', $query['perm'] ); + } + + public function test_logged_in_non_member_of_public_group_keeps_capability_restriction() { + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:60' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( 50, $query['bp_group'] ); + $this->assertSame( 'readable', $query['perm'] ); + } + + public function test_member_of_public_group_bypasses_capability_restriction() { + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:50' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( '', $query['perm'] ); + } + + public function test_moderator_bypasses_capability_restriction_on_public_group() { + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['user_can'] = array( 'bp_moderate' => true ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( '', $query['perm'] ); + } + + public function test_denied_scope_leaves_capability_restriction_in_place() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( 'readable', $query['perm'] ); + } +} diff --git a/tests/unit/BpeoGroupEventCapsTest.php b/tests/unit/BpeoGroupEventCapsTest.php index 66ee8d0e9..f7f8ccf36 100644 --- a/tests/unit/BpeoGroupEventCapsTest.php +++ b/tests/unit/BpeoGroupEventCapsTest.php @@ -178,4 +178,28 @@ public function test_non_event_cap_is_unchanged() { $this->assertSame( array( 'edit_posts' ), $caps ); } + + public function test_read_private_events_not_granted_without_event_context() { + // The primitive cap carries no event, so group membership cannot be + // evaluated; it must keep WordPress's role-based mapping rather than + // being handed to everyone (which let anyone list private events). + $caps = hc_custom_bpeo_group_event_meta_cap( array( 'read_private_events' ), 'read_private_events', 20, array() ); + + $this->assertSame( array( 'read_private_events' ), $caps ); + } + + public function test_read_private_events_not_granted_to_anonymous_user() { + $caps = hc_custom_bpeo_group_event_meta_cap( array( 'read_private_events' ), 'read_private_events', 0, array() ); + + $this->assertSame( array( 'read_private_events' ), $caps ); + } + + public function test_private_event_not_readable_by_non_member() { + $this->setUpGroupEvent( 408, 45, 'private' ); + $GLOBALS['_hc_mock']['user_groups'] = array( 21 => array( 46 ) ); + + $caps = hc_custom_bpeo_group_event_meta_cap( array( 'read_private_events' ), 'read_event', 21, array( 408 ) ); + + $this->assertSame( array( 'read_private_events' ), $caps ); + } } diff --git a/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php b/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php new file mode 100644 index 000000000..438ec0c27 --- /dev/null +++ b/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php @@ -0,0 +1,140 @@ + 'wp_posts', + 'term_relationships' => 'wp_term_relationships', + 'term_taxonomy' => 'wp_term_taxonomy', + 'terms' => 'wp_terms', + ); + } + + /** + * Log in user 3 as a member of groups 50 and 60. + */ + private function setUpMemberWithPrivateEvents() { + $GLOBALS['_hc_mock']['current_user_id'] = 3; + $GLOBALS['_hc_mock']['user_groups'] = array( 3 => array( 50, 60 ) ); + } + + // ----------------------------------------------------------------------- + // The calendar query. + // ----------------------------------------------------------------------- + + public function test_unscoped_query_unchanged_for_anonymous_caller() { + $in = array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + public function test_unscoped_query_unchanged_for_user_in_no_groups() { + $GLOBALS['_hc_mock']['current_user_id'] = 4; + + $in = array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + public function test_unscoped_query_admits_members_private_group_events() { + $this->setUpMemberWithPrivateEvents(); + + $out = hc_custom_bpeo_filter_calendar_query_for_member_groups( array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ) ); + + $this->assertSame( array( 50, 60 ), $out['hc_bpeo_member_group_ids'] ); + $this->assertSame( '', $out['perm'] ); + $this->assertContains( 'private', (array) $out['post_status'] ); + $this->assertContains( 'publish', (array) $out['post_status'] ); + } + + public function test_unscoped_query_unchanged_for_caller_whose_role_reads_private_events() { + // An editor/admin is a member of groups with private events (7, 9) but + // is also entitled, by role, to private events outside those groups + // (e.g. 11). The membership-based restriction must not be applied on + // top of EO's 'readable' query, which already lets them see all of them. + $this->setUpMemberWithPrivateEvents(); + $GLOBALS['_hc_mock']['user_can'] = array( 'read_private_events' => true ); + + $in = array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ); + $out = hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ); + + $this->assertSame( $in, $out ); + $this->assertArrayNotHasKey( 'hc_bpeo_member_group_ids', $out ); + } + + public function test_group_scoped_query_is_left_alone() { + $this->setUpMemberWithPrivateEvents(); + + $in = array( 'bp_group' => 50, 'perm' => '' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + public function test_denied_group_scope_is_left_alone() { + $this->setUpMemberWithPrivateEvents(); + + $in = array( 'bp_group' => array(), 'perm' => 'readable' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + public function test_member_calendar_query_is_left_alone() { + $this->setUpMemberWithPrivateEvents(); + + $in = array( 'bp_displayed_user_id' => 3, 'perm' => 'readable' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + // ----------------------------------------------------------------------- + // The SQL restriction that keeps other private events out. + // ----------------------------------------------------------------------- + + public function test_where_clause_untouched_without_visible_private_events() { + $q = new WP_Query( array( 'post_type' => 'event' ) ); + + $this->assertSame( ' AND 1=1', hc_custom_bpeo_restrict_private_events_where( ' AND 1=1', $q ) ); + } + + public function test_where_clause_limits_private_posts_to_member_groups_and_own_posts() { + // The restriction must be expressed against the group connection + // taxonomy inside the main query, so it is bounded by that query's + // date range rather than by the groups' whole event history. + $GLOBALS['_hc_mock']['current_user_id'] = 3; + $q = new WP_Query( array( 'post_type' => 'event', 'hc_bpeo_member_group_ids' => array( 50, 60 ) ) ); + + $where = hc_custom_bpeo_restrict_private_events_where( ' AND 1=1', $q ); + + $this->assertStringStartsWith( ' AND 1=1', $where ); + $this->assertStringContainsString( "wp_posts.post_status <> 'private'", $where ); + $this->assertStringContainsString( 'wp_posts.post_author = 3', $where ); + $this->assertStringContainsString( "taxonomy = 'bpeo_event_group'", $where ); + $this->assertStringContainsString( "'group_50','group_60'", $where ); + $this->assertStringContainsString( 'wp_term_relationships', $where ); + } + + public function test_where_clause_only_ever_contains_integer_group_ids() { + $GLOBALS['_hc_mock']['current_user_id'] = 3; + $q = new WP_Query( array( 'hc_bpeo_member_group_ids' => array( '50', "60) OR (1=1", 'x', 0 ) ) ); + + $where = hc_custom_bpeo_restrict_private_events_where( '', $q ); + + $this->assertStringContainsString( "'group_50','group_60'", $where ); + $this->assertStringNotContainsString( '1=1', $where ); + $this->assertStringNotContainsString( "'group_0'", $where ); + } +} diff --git a/tests/unit/group-permissions-loader.php b/tests/unit/group-permissions-loader.php index 601d95c60..32a16211b 100644 --- a/tests/unit/group-permissions-loader.php +++ b/tests/unit/group-permissions-loader.php @@ -342,6 +342,13 @@ function bpeo_get_group_minimum_member_role_for_connection( $group_id ) { if ( ! function_exists( 'bpeo_get_group_permalink' ) ) { function bpeo_get_group_permalink( $group = null ) { + // Per-group permalinks may be configured via 'events_group_permalinks' + // (keyed by group ID); otherwise fall back to the single default. + $group_id = is_object( $group ) ? ( $group->id ?? 0 ) : (int) $group; + $map = _hc_mock( 'events_group_permalinks', array() ); + if ( $group_id && isset( $map[ $group_id ] ) ) { + return $map[ $group_id ]; + } return _hc_mock( 'events_group_permalink', 'https://example.org/groups/test-group/events/' ); } } @@ -358,6 +365,28 @@ function bpeo_get_events_new_slug() { } } +if ( ! class_exists( 'WP_Query' ) ) { + /** + * Minimal WP_Query stand-in: records its args and returns whatever the + * per-test callback in $GLOBALS['_hc_mock']['wp_query_callback'] yields + * for them (default: no posts). + */ + class WP_Query { + public $query_vars = array(); + public $posts = array(); + + public function __construct( $args = array() ) { + $this->query_vars = $args; + $cb = _hc_mock( 'wp_query_callback' ); + $this->posts = is_callable( $cb ) ? call_user_func( $cb, $args ) : array(); + } + + public function get( $var, $default = '' ) { + return isset( $this->query_vars[ $var ] ) ? $this->query_vars[ $var ] : $default; + } + } +} + // --------------------------------------------------------------------------- // Load the real code under test. // ---------------------------------------------------------------------------