diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1bf522e..e03d36197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ += 1.10.1 = + +This release announces the end of support for the free plugin, and ships some stability and hardening improvements. + +* The free Progress Planner plugin has reached the end of its support and updates. A notice now appears on the Progress Planner dashboard and the plugins page, and you can download a certificate of appreciation with your site's achievements. + +Maintenance: + +* Hardened capability and permission checks across the plugin. + +Bugs we fixed: + +* Fix a fatal error on PHP 7.4 that could affect every wp-admin page. +* Fix the monthly badge date range so activity is counted in the correct month. +* The email-test completion link is no longer invalidated by other page loads. +* The "Set page" tasks now require a page to be selected before completing. +* Fix the "Create this page" link opening in a new tab from within a popover. + = 1.10.0 = Enhancements: diff --git a/assets/css/admin.css b/assets/css/admin.css index 58938bef6..c8862528c 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -324,6 +324,22 @@ button.prpl-info-icon { } } +/*------------------------------------*\ + Sunset notice. +\*------------------------------------*/ +.prpl-wrap .prpl-sunset-notice { + border-left: 4px solid var(--prpl-background-banner); + + h1 { + color: var(--prpl-color-headings); + margin-top: 0; + } + + p { + max-width: 680px; + } +} + /*------------------------------------*\ Buttons \*------------------------------------*/ diff --git a/classes/admin/class-sunset-notice.php b/classes/admin/class-sunset-notice.php new file mode 100644 index 000000000..e7a6b4bc8 --- /dev/null +++ b/classes/admin/class-sunset-notice.php @@ -0,0 +1,191 @@ +should_show() ) { + return; + } + + \add_action( 'progress_planner_admin_page_header_before', [ $this, 'the_dashboard_notice' ] ); + \add_action( 'after_plugin_row_' . \plugin_basename( \PROGRESS_PLANNER_FILE ), [ $this, 'the_plugin_row_notice' ] ); + \add_action( 'admin_post_' . self::CERTIFICATE_ACTION, [ $this, 'render_certificate' ] ); + } + + /** + * Whether the sunset notice should be shown. + * + * The notice only applies to installs coming from wordpress.org or FAIR. + * It is hidden when the pp-hosts companion plugin is active (constants or + * class detection), when a host defines a branding ID, or when pp-hosts + * is installed but not (yet) activated. + * + * @return bool + */ + public function should_show() { + $show = ! ( + \defined( 'PP_HOSTS_FILE' ) + || \defined( 'PROGRESS_PLANNER_BRANDING_ID' ) + || \class_exists( 'PP_Hosts\Updater' ) + || \progress_planner()->get_plugin_installer()->is_plugin_installed( 'pp-hosts' ) + ); + + /** + * Filter whether the sunset notice should be shown. + * + * @param bool $show Whether the notice should be shown. + */ + return (bool) \apply_filters( 'progress_planner_show_sunset_notice', $show ); + } + + /** + * Get the notice message, shared by both surfaces. + * + * @return string + */ + public function get_message() { + return \esc_html__( 'Support and updates for the free Progress Planner plugin have ended. We are incredibly grateful for your support over the years — thank you for improving your website with us!', 'progress-planner' ); + } + + /** + * Get the certificate invitation sentence. + * + * Contains an anchor tag, so it should be escaped with `wp_kses_post` on output. + * + * @return string + */ + public function get_certificate_message() { + return \sprintf( + /* translators: %1$s: opening tag, %2$s: closing tag. */ + \esc_html__( 'As a keepsake, you can %1$sdownload a certificate%2$s with your site\'s achievements.', 'progress-planner' ), + '', + '' + ); + } + + /** + * Get the URL of the certificate page. + * + * @return string + */ + public function get_certificate_url() { + return \admin_url( 'admin-post.php?action=' . self::CERTIFICATE_ACTION ); + } + + /** + * Get all completed badges, across all badge contexts. + * + * @return \Progress_Planner\Badges\Badge[] + */ + public function get_completed_badges() { + $completed = []; + foreach ( [ 'content', 'maintenance', 'monthly_flat' ] as $context ) { + foreach ( \progress_planner()->get_badges()->get_badges( $context ) as $badge ) { + $progress = $badge->get_progress(); + if ( isset( $progress['progress'] ) && 100 === (int) $progress['progress'] ) { + $completed[] = $badge; + } + } + } + return $completed; + } + + /** + * Render the standalone certificate page and exit. + * + * Hooked to admin-post, so this renders outside the WP admin chrome. + * + * @return void + */ + public function render_certificate() { + if ( ! \current_user_can( 'edit_others_posts' ) ) { + \wp_die( \esc_html__( 'You do not have permission to view this page.', 'progress-planner' ) ); + } + $this->the_certificate(); + exit; + } + + /** + * Print the certificate page markup. + * + * @return void + */ + public function the_certificate() { + \progress_planner()->the_view( 'sunset-certificate.php' ); + } + + /** + * Print the notice banner on the Progress Planner dashboard. + * + * @return void + */ + public function the_dashboard_notice() { + \progress_planner()->the_view( 'sunset-notice.php' ); + } + + /** + * Print a notice row below the plugin's row on the plugins overview page. + * + * @param string $plugin_file Path to the plugin file relative to the plugins directory. + * + * @return void + */ + public function the_plugin_row_notice( $plugin_file ) { + if ( ! \current_user_can( 'activate_plugins' ) ) { + return; + } + + $prpl_colspan = 4; + if ( \function_exists( '_get_list_table' ) ) { + $prpl_colspan = \_get_list_table( 'WP_Plugins_List_Table', [ 'screen' => 'plugins' ] )->get_column_count(); + } + + $prpl_is_active = \function_exists( 'is_plugin_active' ) && \is_plugin_active( $plugin_file ); + ?> + + +
+

get_message() . ' ' . $this->get_certificate_message() ); ?>

+
+ + + + get_year(); - $month = self::get_months( (int) $year )[ 'm' . $this->get_month() ]; $month_num = (int) $this->get_month(); - $start_date = \DateTime::createFromFormat( 'Y-m-d', "{$year}-{$month_num}-01" ); - $end_date = \DateTime::createFromFormat( 'Y-m-d', "{$year}-{$month_num}-" . \gmdate( 't', \strtotime( $month ) ) ); + // Derive the month window from the month number. The previous code used + // gmdate( 't', strtotime( $badge_name ) ), but the badge name (e.g. + // "Felix February") is not a parseable date, so strtotime returned false + // and gmdate( 't', false ) always yielded 31 — overflowing shorter months + // into the next one (Feb -> Mar 3). This matches the monthly-badges + // widget's Y-m-01 / last-day-of-month range. + $start_date = \DateTime::createFromFormat( '!Y-n-j', "{$year}-{$month_num}-1" ); + $end_date = ( clone $start_date )->modify( 'last day of this month' ); // Get the activities for the month. $activities = \progress_planner()->get_activities__query()->query_activities( diff --git a/classes/class-base.php b/classes/class-base.php index 0f55de1c7..1c0c15867 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -98,8 +98,18 @@ public function init() { $this->get_utils__playground(); } - $prpl_license_key = $this->get_license_key(); - if ( ! $prpl_license_key && 0 !== (int) \progress_planner()->get_ui__branding()->get_branding_id() ) { + // Auto-onboard branded hosts (#633). Require `manage_options` so + // anonymous and lower-privileged visitors (e.g. Subscribers) can't + // trigger the blocking remote calls (1.10.0 audit S3). Not gated on + // `is_admin()`: pp-hosts redirects the admin to the front-end homepage + // after set-up, and this is the only path that fetches the license key. + // The branding check runs before the capability check, so + // `current_user_can()` is only reached on a branded, unlicensed site. + if ( ! \wp_doing_cron() + && ! $this->get_license_key() + && 0 !== (int) \progress_planner()->get_ui__branding()->get_branding_id() + && \current_user_can( 'manage_options' ) + ) { $prpl_license_key = \progress_planner()->get_utils__onboard()->make_remote_onboarding_request(); if ( '' !== $prpl_license_key ) { \update_option( 'progress_planner_license_key', $prpl_license_key, false ); @@ -110,6 +120,7 @@ public function init() { if ( \is_admin() && \current_user_can( 'edit_others_posts' ) ) { $this->get_admin__page(); $this->get_admin__tour(); + $this->get_admin__sunset_notice(); // Dont add the widget if the privacy policy is not accepted. if ( true === $this->is_privacy_policy_accepted() ) { diff --git a/classes/class-suggested-tasks-db.php b/classes/class-suggested-tasks-db.php index e1444bf08..e6b0d745f 100644 --- a/classes/class-suggested-tasks-db.php +++ b/classes/class-suggested-tasks-db.php @@ -109,6 +109,24 @@ public function add( $data ) { $posts = $posts_trashed; } + // Defense in depth: only treat an existing task as "the task" when its + // provider matches. Otherwise a post that merely squats the slug (e.g. + // one a non-admin created under a different provider) would be treated + // as the task and suppress the real recommendation from being injected. + // See the 1.10.0 audit S1 / review gap 2. Filtered in PHP on the resolved + // provider rather than via a tax_query, because a name + tax_query on a + // trashed post does not reliably AND-combine in WP_Query. + if ( ! empty( $posts ) && ! empty( $data['provider_id'] ) ) { + $posts = \array_values( + \array_filter( + $posts, + static function ( $task ) use ( $data ) { + return $task->get_provider_id() === $data['provider_id']; + } + ) + ); + } + // If task already exists (in any status), return its ID without creating a duplicate. if ( ! empty( $posts ) ) { \delete_option( $lock_key ); diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php index d09ae5bae..70542077b 100644 --- a/classes/class-suggested-tasks.php +++ b/classes/class-suggested-tasks.php @@ -387,6 +387,34 @@ public function register_post_type() { 'menu_position' => 5, 'hierarchical' => true, 'exclude_from_search' => true, + // Map every meta-capability to the plugin's own gate. Without + // this the CPT inherits the default `post` capabilities, so any + // Contributor/Author (`edit_posts`) could create, edit, trash or + // enumerate recommendations through paths that DO check caps — + // the REST controller is guarded separately, but XML-RPC + // (`wp.newPost`) and the block editor are not (1.10.0 audit S1). + // Internal task injection is unaffected: `Suggested_Tasks_DB` + // writes with raw `wp_insert_post()`/`wp_update_post()`, which do + // not run capability checks. + // With `map_meta_cap => true`, only the PRIMITIVE capabilities are + // listed here; core derives the meta capabilities (edit_post, + // read_post, delete_post) from them per-post. Listing the meta + // caps too triggers a `_doing_it_wrong` notice in WP 6.1+. + 'capability_type' => 'prpl_recommendation', + 'map_meta_cap' => true, + 'capabilities' => [ + 'edit_posts' => 'edit_others_posts', + 'edit_others_posts' => 'edit_others_posts', + 'delete_posts' => 'edit_others_posts', + 'delete_others_posts' => 'edit_others_posts', + 'publish_posts' => 'edit_others_posts', + 'read_private_posts' => 'edit_others_posts', + 'create_posts' => 'edit_others_posts', + 'delete_private_posts' => 'edit_others_posts', + 'delete_published_posts' => 'edit_others_posts', + 'edit_private_posts' => 'edit_others_posts', + 'edit_published_posts' => 'edit_others_posts', + ], ] ); @@ -447,6 +475,15 @@ public function register_taxonomy() { 'rewrite' => [ 'slug' => 'prpl_recommendations_provider' ], 'show_in_rest' => true, 'show_in_menu' => \apply_filters( 'progress_planner_tasks_show_ui', false ), + // Gate term writes to the same capability the plugin UI uses, so + // a Contributor/Author (default `edit_posts`) cannot assign, + // create or alter provider terms via core REST (audit S1). + 'capabilities' => [ + 'manage_terms' => 'edit_others_posts', + 'edit_terms' => 'edit_others_posts', + 'delete_terms' => 'edit_others_posts', + 'assign_terms' => 'edit_others_posts', + ], ] ); } @@ -467,7 +504,7 @@ public function rest_api_tax_query( $args, $request ) { $tax_query[] = [ 'taxonomy' => 'prpl_recommendations_provider', 'field' => 'slug', - 'terms' => \explode( ',', $request['exclude_provider'] ), + 'terms' => $this->parse_provider_param( $request['exclude_provider'] ), 'operator' => 'NOT IN', ]; } @@ -480,7 +517,7 @@ public function rest_api_tax_query( $args, $request ) { // Include terms (matches any term in list). if ( isset( $request['provider'] ) ) { - $request_providers = \explode( ',', $request['provider'] ); + $request_providers = $this->parse_provider_param( $request['provider'] ); $include_providers = \array_intersect( $include_providers, $request_providers ); } @@ -508,6 +545,26 @@ public function rest_api_tax_query( $args, $request ) { return $args; } + /** + * Normalise a `provider` / `exclude_provider` REST param to a list of slugs. + * + * The param may arrive as a comma-separated string (`?provider=a,b`) or as + * an array (`?provider[]=a&provider[]=b`). Passing an array straight to + * `explode()` throws a TypeError on PHP 8, so branch on the input type and + * sanitise each slug. + * + * @param mixed $value The raw request value. + * + * @return string[] The list of provider slugs. + */ + protected function parse_provider_param( $value ) { + $providers = \is_string( $value ) + ? \explode( ',', $value ) + : (array) $value; + + return \array_values( \array_filter( \array_map( 'sanitize_key', $providers ) ) ); + } + /** * Sanitize a recommendation before it is inserted or updated via the REST API. * diff --git a/classes/rest/class-recommendations-controller.php b/classes/rest/class-recommendations-controller.php index 4a2bcca47..9f40b1860 100644 --- a/classes/rest/class-recommendations-controller.php +++ b/classes/rest/class-recommendations-controller.php @@ -12,6 +12,206 @@ */ class Recommendations_Controller extends \WP_REST_Posts_Controller { + /** + * The capability required to read or write recommendations over REST. + * + * This mirrors the gate the rest of the plugin uses for its admin UI + * (see `Base::init()` and `Admin\Dashboard_Widget::$capability`), so the + * REST surface can never be reached by roles that cannot see the feature. + * + * Without these overrides the `prpl_recommendations` CPT inherits the + * default `post` capabilities: any Contributor/Author (`edit_posts`) could + * create, alter, trash and enumerate tasks, and any published task was + * readable anonymously. See the 1.10.0 release audit (S1/S2). + * + * @var string + */ + const REQUIRED_CAPABILITY = 'edit_others_posts'; + + /** + * Whether the current user may access the recommendations REST surface. + * + * @return bool + */ + protected function current_user_can_access_recommendations() { + return \current_user_can( self::REQUIRED_CAPABILITY ); + } + + /** + * Whether the current user may act on the specific task identified by the + * request, based on the task's own provider capability. + * + * This mirrors the per-task gate the AJAX handler applies + * (`Suggested_Tasks::suggested_task_action()`): e.g. an admin-only task such + * as `update-core` requires `update_core`, so an editor cannot read, trash + * or delete it over REST even though they clear the surface-level + * `edit_others_posts` gate (audit S1, review gap 2). Tasks whose provider + * cannot be resolved fall back to the surface gate. + * + * @param \WP_REST_Request $request The REST request. + * + * @return bool + */ + protected function current_user_can_manage_requested_task( $request ) { + $post = $this->get_post( $request['id'] ); + if ( \is_wp_error( $post ) ) { + return true; + } + + $task = \progress_planner()->get_suggested_tasks_db()->get_post( $post->ID ); + if ( ! $task ) { + return true; + } + + $provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task->get_provider_id() ); + if ( ! $provider ) { + return true; + } + + return (bool) $provider->capability_required(); + } + + /** + * Build the shared "insufficient permissions" error. + * + * @return \WP_Error + */ + protected function forbidden_error() { + return new \WP_Error( + 'rest_forbidden', + \__( 'Sorry, you are not allowed to access Progress Planner recommendations.', 'progress-planner' ), + [ 'status' => \rest_authorization_required_code() ] + ); + } + + /** + * Check permissions for reading a collection of recommendations. + * + * @param \WP_REST_Request $request The REST request. + * + * @return true|\WP_Error + */ + public function get_items_permissions_check( $request ) { + if ( ! $this->current_user_can_access_recommendations() ) { + return $this->forbidden_error(); + } + return parent::get_items_permissions_check( $request ); + } + + /** + * Check permissions for reading a single recommendation. + * + * Core's `WP_REST_Posts_Controller::check_read_permission()` allows any + * `publish` post to be read anonymously, so overriding this method is the + * only way to close anonymous single-item reads (audit S2). + * + * @param \WP_REST_Request $request The REST request. + * + * @return true|\WP_Error + */ + public function get_item_permissions_check( $request ) { + if ( ! $this->current_user_can_access_recommendations() + || ! $this->current_user_can_manage_requested_task( $request ) + ) { + return $this->forbidden_error(); + } + return parent::get_item_permissions_check( $request ); + } + + /** + * Check permissions for creating a recommendation. + * + * @param \WP_REST_Request $request The REST request. + * + * @return true|\WP_Error + */ + public function create_item_permissions_check( $request ) { + if ( ! $this->current_user_can_access_recommendations() ) { + return $this->forbidden_error(); + } + + // Users without `manage_options` may only create their own personal + // (`user`) to-dos: they must not be able to create a task under any other + // provider, nor squat a known task's slug in order to suppress it (an + // admin-only recommendation is treated as "already existing" by + // Suggested_Tasks_DB::add() once a post with its slug exists). See the + // 1.10.0 audit S1 / review gap 2 (create half). Administrators are + // unrestricted (the plugin creates its provider tasks as an admin). + if ( ! \current_user_can( 'manage_options' ) && ! $this->create_is_user_task_only( $request ) ) { + return $this->forbidden_error(); + } + + return parent::create_item_permissions_check( $request ); + } + + /** + * Whether a create request is limited to a personal (`user`) to-do: it may + * carry only the `user` provider term (or none) and must not set a slug. + * + * @param \WP_REST_Request $request The REST request. + * + * @return bool + */ + protected function create_is_user_task_only( $request ) { + // A client-supplied slug can be used to squat a known task; disallow it. + if ( '' !== (string) $request['slug'] ) { + return false; + } + + $requested_terms = $request['prpl_recommendations_provider']; + if ( empty( $requested_terms ) ) { + return true; + } + + $user_term = \get_term_by( 'slug', 'user', 'prpl_recommendations_provider' ); + $user_term_id = $user_term ? (int) $user_term->term_id : 0; + + foreach ( (array) $requested_terms as $term ) { + // Terms may arrive as IDs (from wp.api models) or slugs. + if ( \is_numeric( $term ) ) { + if ( (int) $term !== $user_term_id ) { + return false; + } + } elseif ( 'user' !== $term ) { + return false; + } + } + + return true; + } + + /** + * Check permissions for updating a recommendation. + * + * @param \WP_REST_Request $request The REST request. + * + * @return true|\WP_Error + */ + public function update_item_permissions_check( $request ) { + if ( ! $this->current_user_can_access_recommendations() + || ! $this->current_user_can_manage_requested_task( $request ) + ) { + return $this->forbidden_error(); + } + return parent::update_item_permissions_check( $request ); + } + + /** + * Check permissions for deleting a recommendation. + * + * @param \WP_REST_Request $request The REST request. + * + * @return true|\WP_Error + */ + public function delete_item_permissions_check( $request ) { + if ( ! $this->current_user_can_access_recommendations() + || ! $this->current_user_can_manage_requested_task( $request ) + ) { + return $this->forbidden_error(); + } + return parent::delete_item_permissions_check( $request ); + } + /** * Get the item schema for recommendations (tasks) in the REST API. * diff --git a/classes/suggested-tasks/providers/class-email-sending.php b/classes/suggested-tasks/providers/class-email-sending.php index a1be12d19..50f9e3064 100644 --- a/classes/suggested-tasks/providers/class-email-sending.php +++ b/classes/suggested-tasks/providers/class-email-sending.php @@ -61,13 +61,6 @@ class Email_Sending extends Tasks_Interactive { */ protected $email_subject = ''; - /** - * The email content. - * - * @var string - */ - protected $email_content = ''; - /** * The error. * @@ -110,16 +103,11 @@ public function init() { $this->email_subject = \esc_html__( 'Your Progress Planner test message!', 'progress-planner' ); - // Generate a secure token for the completion link to prevent CSRF. - $user_id = \get_current_user_id(); - $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( $this->get_task_id(), $user_id ); - - $this->email_content = \sprintf( - // translators: %1$s the admin URL. - \__( 'You just used Progress Planner to verify if sending email works on your website.

The good news; it does! Click here to mark %2$s\'s Recommendation as completed.', 'progress-planner' ), - \admin_url( 'admin.php?page=progress-planner&prpl_complete_task=' . $this->get_task_id() . '&token=' . $token ), - \esc_html( \progress_planner()->get_ui__branding()->get_ravi_name() ) - ); + // Note: the completion token and email body are generated on demand in + // ajax_test_email_sending(), not here. Minting a token on every `init` + // would overwrite the token stored when the email was actually sent + // (invalidating the link in the user's inbox on the next request), and + // $this->email_content is not read anywhere else. } /** diff --git a/classes/suggested-tasks/providers/class-set-page-task.php b/classes/suggested-tasks/providers/class-set-page-task.php index 27e889967..2b9eb2a15 100644 --- a/classes/suggested-tasks/providers/class-set-page-task.php +++ b/classes/suggested-tasks/providers/class-set-page-task.php @@ -155,6 +155,13 @@ public static function handle_interactive_task_specific_submit() { \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid page value.', 'progress-planner' ) ] ); } + // "I have this page" must name an actual page. Without this guard the + // task completes with have_page = 'yes' but id = 0, i.e. it records that + // a page exists while pointing at no page at all. + if ( 'yes' === $have_page && 1 > $id ) { + \wp_send_json_error( [ 'message' => \esc_html__( 'Please select a page.', 'progress-planner' ) ] ); + } + // Extract page name from task ID (e.g., "set-page-about" -> "about"). $page_name = \str_replace( 'set-page-', '', $task_id ); if ( empty( $page_name ) ) { diff --git a/classes/suggested-tasks/providers/class-user.php b/classes/suggested-tasks/providers/class-user.php index 91e6b13b8..f57a5994b 100644 --- a/classes/suggested-tasks/providers/class-user.php +++ b/classes/suggested-tasks/providers/class-user.php @@ -12,6 +12,18 @@ */ class User extends Tasks { + /** + * The capability required to manage user (personal to-do) tasks. + * + * These are the user's own to-do items, shown to the dashboard-widget + * audience (editors and up). Without this override the provider inherits the + * base `manage_options`, which would stop editors from completing their own + * to-dos even though the widget shows them. + * + * @var string + */ + protected const CAPABILITY = 'edit_others_posts'; + /** * Whether the task is dismissable. * diff --git a/classes/ui/class-branding.php b/classes/ui/class-branding.php index 32c3ac795..0732a7db5 100644 --- a/classes/ui/class-branding.php +++ b/classes/ui/class-branding.php @@ -54,7 +54,11 @@ public function get_branding_id(): int { if ( \defined( 'PROGRESS_PLANNER_BRANDING_ID' ) ) { return \constant( 'PROGRESS_PLANNER_BRANDING_ID' ); } - if ( isset( $_GET['pp_branding_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + // The `pp_branding_id` URL param is a preview seam for branded-host + // rendering. Honour it only for administrators, so no lower-privileged + // or anonymous visitor can steer the branding ID and thereby trigger the + // auto-onboard remote call in `Base::init()` (1.10.0 audit S3). + if ( isset( $_GET['pp_branding_id'] ) && \current_user_can( 'manage_options' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return (int) $_GET['pp_branding_id']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended } @@ -220,7 +224,7 @@ public function get_admin_submenu_name(): string { * * @return int|null */ - public function get_admin_submenu_position(): mixed { + public function get_admin_submenu_position() { if ( $this->get_branding_id() !== 0 && $this->get_branding_id() !== 4958 ) { return -1000; } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ca24110c3..7035cb279 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,9 @@ parameters: level: 10 + # The plugin supports PHP 7.4 (see "Requires PHP" in progress-planner.php). + # Pinning this makes PHPStan reject PHP 8-only syntax (union types, "mixed", + # "never", ...) that "php -l" accepts on 7.4 but that fatals at runtime. + phpVersion: 70400 reportUnmatchedIgnoredErrors: false paths: - ./classes diff --git a/progress-planner.php b/progress-planner.php index f3fbb83d5..f65ab7daa 100644 --- a/progress-planner.php +++ b/progress-planner.php @@ -9,7 +9,7 @@ * Description: A plugin to help you fight procrastination and get things done. * Requires at least: 6.7 * Requires PHP: 7.4 - * Version: 1.10.0 + * Version: 1.10.1 * Author: Team Emilia Projects * Author URI: https://prpl.fyi/about * License: GPL-3.0+ diff --git a/readme.txt b/readme.txt index 4f0a071b3..7136537fc 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: planning, maintenance, writing, blogging Requires at least: 6.7 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 1.10.0 +Stable tag: 1.10.1 License: GPL3+ License URI: https://www.gnu.org/licenses/gpl-3.0.en.html @@ -83,6 +83,24 @@ https://youtu.be/e1bmxZYyXFY == Changelog == += 1.10.1 = + +This release announces the end of support for the free plugin, and ships some stability and hardening improvements. + +* The free Progress Planner plugin has reached the end of its support and updates. A notice now appears on the Progress Planner dashboard and the plugins page, and you can download a certificate of appreciation with your site's achievements. + +Maintenance: + +* Hardened capability and permission checks across the plugin. + +Bugs we fixed: + +* Fix a fatal error on PHP 7.4 that could affect every wp-admin page. +* Fix the monthly badge date range so activity is counted in the correct month. +* The email-test completion link is no longer invalidated by other page loads. +* The "Set page" tasks now require a page to be selected before completing. +* Fix the "Create this page" link opening in a new tab from within a popover. + = 1.10.0 = Enhancements: diff --git a/tests/phpunit/test-class-branding-preview-param.php b/tests/phpunit/test-class-branding-preview-param.php new file mode 100644 index 000000000..4809f7dc4 --- /dev/null +++ b/tests/phpunit/test-class-branding-preview-param.php @@ -0,0 +1,86 @@ +get_ui__branding()->get_branding_id(); + + $this->assertSame( + 0, + $branding_id, + 'anonymous pp_branding_id must be ignored (default 0), got ' . $branding_id + ); + } + + /** + * A logged-in admin can still use the param to preview branding. + * + * @return void + */ + public function test_logged_in_admin_param_is_honoured() { + $admin = self::factory()->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $admin ); + $_GET['pp_branding_id'] = '4958'; + + $branding_id = \progress_planner()->get_ui__branding()->get_branding_id(); + + $this->assertSame( + 4958, + $branding_id, + 'logged-in admin pp_branding_id should be honoured' + ); + } + + /** + * A logged-in non-admin (e.g. subscriber) must NOT be able to steer the + * branding ID: the param is gated on `manage_options`, since a non-zero + * branding ID can trigger the auto-onboard remote call. + * + * @return void + */ + public function test_logged_in_subscriber_param_is_ignored() { + $subscriber = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + \wp_set_current_user( $subscriber ); + $_GET['pp_branding_id'] = '4958'; + + $branding_id = \progress_planner()->get_ui__branding()->get_branding_id(); + + $this->assertSame( 0, $branding_id, 'a subscriber must not be able to set pp_branding_id' ); + } +} diff --git a/tests/phpunit/test-class-email-sending-token.php b/tests/phpunit/test-class-email-sending-token.php new file mode 100644 index 000000000..7ecba6d97 --- /dev/null +++ b/tests/phpunit/test-class-email-sending-token.php @@ -0,0 +1,94 @@ +user_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $this->user_id ); + + $this->provider = \progress_planner() + ->get_suggested_tasks() + ->get_tasks_manager() + ->get_task_provider( 'sending-email' ); + + $this->assertNotNull( $this->provider, 'email-sending provider should be registered' ); + } + + /** + * The transient key that stores the completion token. + * + * @return string + */ + private function token_transient_key() { + return 'prpl_complete_' . $this->provider->get_task_id() . '_' . $this->user_id; + } + + /** + * Running init() must not overwrite a token stored by the send flow. + * + * @return void + */ + public function test_init_does_not_overwrite_stored_token() { + // Simulate the send flow storing a token for the emailed link. + $sent_token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( + $this->provider->get_task_id(), + $this->user_id + ); + $this->assertSame( + $sent_token, + \get_transient( $this->token_transient_key() ), + 'the sent token should be stored' + ); + + // Simulate an unrelated later request running the provider's init(). + $this->provider->init(); + + // The stored token must be unchanged, so the emailed link still verifies. + $this->assertSame( + $sent_token, + \get_transient( $this->token_transient_key() ), + 'init() must not regenerate/overwrite the stored completion token' + ); + } +} diff --git a/tests/phpunit/test-class-monthly-badge-window.php b/tests/phpunit/test-class-monthly-badge-window.php new file mode 100644 index 000000000..d7cb17354 --- /dev/null +++ b/tests/phpunit/test-class-monthly-badge-window.php @@ -0,0 +1,104 @@ +--31", which DateTime overflows for shorter months — e.g. + * February's window ran to March 3, counting early-March activity toward the + * February badge. + * + * @package Progress_Planner\Tests + */ + +namespace Progress_Planner\Tests; + +use Progress_Planner\Badges\Monthly; +use Progress_Planner\Activities\Suggested_Task as Activity; + +/** + * Class Monthly_Badge_Window_Test + */ +class Monthly_Badge_Window_Test extends \WP_UnitTestCase { + + /** + * Insert a suggested-task activity on a specific date. + * + * @param string $date The activity date (Y-m-d). + * @param int $data_id The activity data id. + * + * @return void + */ + private function insert_activity_on( $date, $data_id ) { + $activity = new Activity(); + $activity->category = 'suggested_task'; + $activity->type = 'test_activity'; + $activity->date = new \DateTime( $date ); + $activity->data_id = $data_id; + $activity->user_id = 1; + $activity->save(); + } + + /** + * Early-March activity must not count toward the February badge. + * + * With the bug, February's window ran to March 3, so an activity on + * March 2 was counted. The correct window ends on February's real last day. + * + * @return void + */ + public function test_march_activity_does_not_count_toward_february() { + // Activity two days into March. + $this->insert_activity_on( '2026-03-02', 5001 ); + + $february = new Monthly( 'monthly-2026-m2' ); + $progress = $february->progress_callback(); + + $this->assertSame( + 0, + $progress['progress'], + 'March activity must not count toward the February badge' + ); + } + + /** + * A February-dated activity still counts toward the February badge, so the + * window has not been narrowed incorrectly. + * + * @return void + */ + public function test_february_activity_counts_toward_february() { + $this->insert_activity_on( '2026-02-15', 5002 ); + + $february = new Monthly( 'monthly-2026-m2' ); + $progress = $february->progress_callback(); + + $this->assertGreaterThan( + 0, + $progress['progress'], + 'a February activity should count toward the February badge' + ); + } + + /** + * Activity on February's real last day (28th, 2026 is not a leap year) + * counts; the window includes the whole month. + * + * @return void + */ + public function test_last_day_of_february_counts() { + $this->insert_activity_on( '2026-02-28', 5003 ); + + $february = new Monthly( 'monthly-2026-m2' ); + $progress = $february->progress_callback(); + + $this->assertGreaterThan( + 0, + $progress['progress'], + 'activity on February 28 should count toward the February badge' + ); + } +} diff --git a/tests/phpunit/test-class-recommendations-create-gate.php b/tests/phpunit/test-class-recommendations-create-gate.php new file mode 100644 index 000000000..d7757b29a --- /dev/null +++ b/tests/phpunit/test-class-recommendations-create-gate.php @@ -0,0 +1,263 @@ +term_id; + } + $created = \wp_insert_term( $slug, 'prpl_recommendations_provider', [ 'slug' => $slug ] ); + return (int) $created['term_id']; + } + + /** + * The `user` provider term id, created if needed. + * + * @return int + */ + private function user_term_id() { + return $this->term_id( 'user' ); + } + + /** + * Run the create permission check as the given user and return whether it is + * allowed. Exercises the controller directly to isolate the authorization + * decision from response preparation. + * + * @param int $user_id The user id. + * @param array $body The request body. + * + * @return bool True if the create is allowed. + */ + private function create_allowed( $user_id, $body ) { + \wp_set_current_user( $user_id ); + $controller = new \Progress_Planner\Rest\Recommendations_Controller( 'prpl_recommendations' ); + $request = new \WP_REST_Request( 'POST', '/wp/v2/prpl_recommendations' ); + $request->set_body_params( $body ); + foreach ( $body as $key => $value ) { + $request->set_param( $key, $value ); + } + return true === $controller->create_item_permissions_check( $request ); + } + + /** + * An editor cannot create a task with a client-supplied slug (slug squat). + * + * @return void + */ + public function test_editor_cannot_create_with_slug() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + $this->assertFalse( + $this->create_allowed( + $editor, + [ + 'title' => 'squat', + 'slug' => 'update-core-202601', + 'status' => 'trash', + ] + ), + 'editor slug-squat create must be forbidden' + ); + } + + /** + * An editor cannot create a task under a non-`user` provider term. + * + * @return void + */ + public function test_editor_cannot_create_under_other_provider() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + $other_id = $this->term_id( 'update-core' ); + $this->assertFalse( + $this->create_allowed( + $editor, + [ + 'title' => 'x', + 'status' => 'publish', + 'prpl_recommendations_provider' => [ $other_id ], + ] + ), + 'editor create under non-user provider must be forbidden' + ); + } + + /** + * An editor CAN create their own `user` to-do (widget flow). + * + * @return void + */ + public function test_editor_can_create_user_todo() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + $this->assertTrue( + $this->create_allowed( + $editor, + [ + 'title' => 'my todo', + 'status' => 'publish', + 'prpl_recommendations_provider' => [ $this->user_term_id() ], + ] + ), + 'editor must be able to create their own user to-do' + ); + } + + /** + * An editor CAN create a plain to-do with no provider term and no slug. + * + * @return void + */ + public function test_editor_can_create_plain_todo() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + $this->assertTrue( + $this->create_allowed( + $editor, + [ + 'title' => 'plain', + 'status' => 'publish', + ] + ), + 'editor plain to-do (no provider/slug) should be allowed' + ); + } + + /** + * An administrator is unrestricted on create (may set a slug). + * + * @return void + */ + public function test_admin_can_create_with_slug() { + $admin = self::factory()->user->create( [ 'role' => 'administrator' ] ); + $this->assertTrue( + $this->create_allowed( + $admin, + [ + 'title' => 'x', + 'slug' => 'admin-chosen-slug', + 'status' => 'publish', + ] + ), + 'admin should be able to create with a slug' + ); + } + + /** + * A slug-squat post under the wrong provider does not suppress the real + * task: add() injects the real recommendation anyway. + * + * @return void + */ + public function test_wrong_provider_squat_does_not_suppress() { + $user_term = $this->user_term_id(); + + // Simulate a squat: a trashed post with the target slug but the `user` + // provider (not `update-core`). + $squat = self::factory()->post->create( + [ + 'post_type' => 'prpl_recommendations', + 'post_name' => 'update-core-x', + 'post_status' => 'trash', + ] + ); + \wp_set_object_terms( $squat, [ $user_term ], 'prpl_recommendations_provider' ); + + $returned = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'update-core-x', + 'post_title' => 'Perform all updates', + 'post_status' => 'publish', + 'provider_id' => 'update-core', + ] + ); + + $this->assertNotSame( $squat, $returned, 'add() must not return the squat post' ); + $new = \get_post( $returned ); + $this->assertNotNull( $new ); + $this->assertSame( 'publish', $new->post_status, 'the real task must be published, not suppressed' ); + $this->assertTrue( + \has_term( 'update-core', 'prpl_recommendations_provider', $returned ), + 'the injected task carries its own provider' + ); + } + + /** + * A matching-provider existing task IS deduped (add() returns it, not a + * duplicate) — the guard does not break legitimate dedup. + * + * @return void + */ + public function test_matching_provider_task_is_deduped() { + $tid = $this->term_id( 'core-siteicon' ); + + $existing = self::factory()->post->create( + [ + 'post_type' => 'prpl_recommendations', + 'post_name' => 'core-siteicon', + 'post_status' => 'trash', + ] + ); + \wp_set_object_terms( $existing, [ $tid ], 'prpl_recommendations_provider' ); + + $returned = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'core-siteicon', + 'post_title' => 'Set site icon', + 'post_status' => 'publish', + 'provider_id' => 'core-siteicon', + ] + ); + + $this->assertSame( $existing, $returned, 'a matching-provider existing task must be deduped' ); + } +} diff --git a/tests/phpunit/test-class-rest-recommendations-permissions.php b/tests/phpunit/test-class-rest-recommendations-permissions.php new file mode 100644 index 000000000..bd6fd213f --- /dev/null +++ b/tests/phpunit/test-class-rest-recommendations-permissions.php @@ -0,0 +1,356 @@ +task_id = self::factory()->post->create( + [ + 'post_type' => 'prpl_recommendations', + 'post_title' => 'Perform all updates', + 'post_name' => 'update-core-202638', + 'post_status' => 'publish', + ] + ); + } + + /** + * Tear down the REST server after each test. + * + * @return void + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + parent::tearDown(); + } + + /** + * Perform a REST request as a given user and return the response. + * + * @param int $user_id The user to act as (0 = anonymous). + * @param string $method HTTP method. + * @param string $route REST route. + * @param array $body The request body params. + * + * @return \WP_REST_Response + */ + private function request_as( $user_id, $method, $route, $body = [] ) { + \wp_set_current_user( $user_id ); + $request = new \WP_REST_Request( $method, $route ); + if ( ! empty( $body ) ) { + $request->set_body_params( $body ); + } + return \rest_do_request( $request ); + } + + /** + * Assert that a response is a permission denial. + * + * @param \WP_REST_Response $response The response. + * @param string $context A label for the failure message. + * + * @return void + */ + private function assertDenied( $response, $context ) { + $this->assertContains( + $response->get_status(), + [ 401, 403 ], + "$context should be denied (401/403), got " . $response->get_status() + ); + } + + /** + * Data provider: roles that must NOT be able to touch the endpoint. + * + * @return array> + */ + public function unauthorized_roles() { + return [ + 'subscriber' => [ 'subscriber' ], + 'contributor' => [ 'contributor' ], + 'author' => [ 'author' ], + ]; + } + + /** + * S2: low-privilege and anonymous users cannot read a single task. + * + * @dataProvider unauthorized_roles + * + * @param string $role The role to test. + * + * @return void + */ + public function test_low_priv_cannot_read_single_item( $role ) { + $user = self::factory()->user->create( [ 'role' => $role ] ); + $response = $this->request_as( $user, 'GET', '/wp/v2/prpl_recommendations/' . $this->task_id ); + $this->assertDenied( $response, "$role single-item read" ); + } + + /** + * S2: anonymous users cannot read a single task. + * + * @return void + */ + public function test_anonymous_cannot_read_single_item() { + $response = $this->request_as( 0, 'GET', '/wp/v2/prpl_recommendations/' . $this->task_id ); + $this->assertDenied( $response, 'anonymous single-item read' ); + } + + /** + * S2: low-privilege and anonymous users cannot read the collection. + * + * @dataProvider unauthorized_roles + * + * @param string $role The role to test. + * + * @return void + */ + public function test_low_priv_cannot_read_collection( $role ) { + $user = self::factory()->user->create( [ 'role' => $role ] ); + $response = $this->request_as( $user, 'GET', '/wp/v2/prpl_recommendations' ); + $this->assertDenied( $response, "$role collection read" ); + } + + /** + * S1: low-privilege users cannot create a task (including slug squatting). + * + * @dataProvider unauthorized_roles + * + * @param string $role The role to test. + * + * @return void + */ + public function test_low_priv_cannot_create( $role ) { + $user = self::factory()->user->create( [ 'role' => $role ] ); + $response = $this->request_as( + $user, + 'POST', + '/wp/v2/prpl_recommendations', + [ + 'title' => 'squat', + 'slug' => 'core-blogdescription', + 'status' => 'trash', + ] + ); + $this->assertDenied( $response, "$role create" ); + } + + /** + * S1: low-privilege users cannot delete an existing task. + * + * @dataProvider unauthorized_roles + * + * @param string $role The role to test. + * + * @return void + */ + public function test_low_priv_cannot_delete( $role ) { + $user = self::factory()->user->create( [ 'role' => $role ] ); + $response = $this->request_as( $user, 'DELETE', '/wp/v2/prpl_recommendations/' . $this->task_id ); + $this->assertDenied( $response, "$role delete" ); + } + + /** + * S1: low-privilege users cannot update an existing task. + * + * @dataProvider unauthorized_roles + * + * @param string $role The role to test. + * + * @return void + */ + public function test_low_priv_cannot_update( $role ) { + $user = self::factory()->user->create( [ 'role' => $role ] ); + $response = $this->request_as( + $user, + 'POST', + '/wp/v2/prpl_recommendations/' . $this->task_id, + [ 'status' => 'trash' ] + ); + $this->assertDenied( $response, "$role update" ); + } + + /** + * Legitimate flow: an Editor (the widget audience) CAN read tasks. + * + * @return void + */ + public function test_editor_can_read() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + + $single = $this->request_as( $editor, 'GET', '/wp/v2/prpl_recommendations/' . $this->task_id ); + $this->assertSame( 200, $single->get_status(), 'editor single-item read should be allowed' ); + + $collection = $this->request_as( $editor, 'GET', '/wp/v2/prpl_recommendations' ); + $this->assertSame( 200, $collection->get_status(), 'editor collection read should be allowed' ); + } + + /** + * Legitimate flow: an Editor CAN create and delete their own `user` task, + * exactly as the to-do widget does. + * + * @return void + */ + public function test_editor_can_create_and_delete_own_task() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + + // The tasks widget creates a user-provider task as the logged-in + // Editor; creation must stay allowed after the fix. + $create = $this->request_as( + $editor, + 'POST', + '/wp/v2/prpl_recommendations', + [ + 'title' => 'My own to-do', + 'status' => 'publish', + ] + ); + $this->assertSame( 201, $create->get_status(), 'editor create should be allowed' ); + $new_id = $create->get_data()['id']; + + $delete = $this->request_as( $editor, 'DELETE', '/wp/v2/prpl_recommendations/' . $new_id, [ 'force' => true ] ); + $this->assertSame( 200, $delete->get_status(), 'editor delete of own task should be allowed' ); + } + + /** + * Legitimate flow: an Editor CAN assign the provider taxonomy term, which + * the tightened `assign_terms` capability must not block. + * + * @return void + */ + public function test_editor_can_assign_provider_term() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + \wp_set_current_user( $editor ); + + $post_id = self::factory()->post->create( + [ + 'post_type' => 'prpl_recommendations', + 'post_status' => 'publish', + ] + ); + $term = \wp_insert_term( 'user-flow', 'prpl_recommendations_provider' ); + $this->assertNotWPError( $term, 'term creation should succeed for editor' ); + + $result = \wp_set_object_terms( $post_id, [ (int) $term['term_id'] ], 'prpl_recommendations_provider' ); + $this->assertNotWPError( $result, 'editor should be able to assign the provider term' ); + $this->assertNotEmpty( $result ); + } + + /** + * Legitimate flow: an admin retains full access. + * + * @return void + */ + public function test_admin_can_read_and_write() { + $admin = self::factory()->user->create( [ 'role' => 'administrator' ] ); + + $read = $this->request_as( $admin, 'GET', '/wp/v2/prpl_recommendations/' . $this->task_id ); + $this->assertSame( 200, $read->get_status(), 'admin read should be allowed' ); + + $update = $this->request_as( + $admin, + 'POST', + '/wp/v2/prpl_recommendations/' . $this->task_id, + [ 'status' => 'trash' ] + ); + $this->assertSame( 200, $update->get_status(), 'admin update should be allowed' ); + } + + /** + * The CPT's mapped capabilities block non-REST write paths too (XML-RPC, + * block editor). Contributors/Authors cannot create; Editors/admins can. + * This closes the slug-squat that the REST permission checks alone left + * open through wp.newPost (audit S1, review gap 1). + * + * @return void + */ + public function test_cpt_create_capability_is_gated() { + $create_cap = \get_post_type_object( 'prpl_recommendations' )->cap->create_posts; + $this->assertSame( 'edit_others_posts', $create_cap, 'create cap must be mapped to edit_others_posts' ); + + foreach ( [ 'subscriber', 'contributor', 'author' ] as $role ) { + $user = self::factory()->user->create( [ 'role' => $role ] ); + \wp_set_current_user( $user ); + $this->assertFalse( + \current_user_can( $create_cap ), + "$role must not be able to create recommendations (any write path)" + ); + } + + foreach ( [ 'editor', 'administrator' ] as $role ) { + $user = self::factory()->user->create( [ 'role' => $role ] ); + \wp_set_current_user( $user ); + $this->assertTrue( + \current_user_can( $create_cap ), + "$role must still be able to create recommendations" + ); + } + } + + /** + * Internal task injection is unaffected by the mapped capabilities: it uses + * raw wp_insert_post(), which does not check caps. Even a Subscriber's + * request can inject a task server-side. + * + * @return void + */ + public function test_internal_injection_ignores_capabilities() { + $subscriber = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + \wp_set_current_user( $subscriber ); + + $id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'core-siteicon', + 'post_title' => 'Set site icon', + 'post_status' => 'publish', + 'provider_id' => 'core-siteicon', + ] + ); + + $this->assertIsInt( $id ); + $this->assertGreaterThan( 0, $id, 'internal add() must work regardless of the current user caps' ); + } +} diff --git a/tests/phpunit/test-class-rest-recommendations-provider-caps.php b/tests/phpunit/test-class-rest-recommendations-provider-caps.php new file mode 100644 index 000000000..000315b05 --- /dev/null +++ b/tests/phpunit/test-class-rest-recommendations-provider-caps.php @@ -0,0 +1,144 @@ +controller = new Recommendations_Controller( 'prpl_recommendations' ); + } + + /** + * Create a published task carrying a given provider term. + * + * @param string $slug The post slug. + * @param string $provider_id The provider term to assign. + * + * @return int The post ID. + */ + private function make_task( $slug, $provider_id ) { + $id = self::factory()->post->create( + [ + 'post_type' => 'prpl_recommendations', + 'post_name' => $slug, + 'post_status' => 'publish', + ] + ); + $term = \get_term_by( 'name', $provider_id, 'prpl_recommendations_provider' ); + if ( ! $term ) { + $created = \wp_insert_term( $provider_id, 'prpl_recommendations_provider' ); + $term_id = (int) $created['term_id']; + } else { + $term_id = (int) $term->term_id; + } + \wp_set_object_terms( $id, [ $term_id ], 'prpl_recommendations_provider' ); + return $id; + } + + /** + * Run a permission check for the given verb and return true/false. + * + * @param int $user_id The user to act as. + * @param string $method GET, POST (update) or DELETE. + * @param int $post_id The task ID. + * + * @return bool True if allowed. + */ + private function is_allowed( $user_id, $method, $post_id ) { + \wp_set_current_user( $user_id ); + $request = new \WP_REST_Request( $method, '/wp/v2/prpl_recommendations/' . $post_id ); + $request->set_param( 'id', $post_id ); + + switch ( $method ) { + case 'DELETE': + $result = $this->controller->delete_item_permissions_check( $request ); + break; + case 'POST': + $result = $this->controller->update_item_permissions_check( $request ); + break; + default: + $result = $this->controller->get_item_permissions_check( $request ); + break; + } + + return true === $result; + } + + /** + * An editor can read, update and delete their own `user` to-do. + * + * @return void + */ + public function test_editor_can_manage_user_task() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + $id = $this->make_task( 'user-todo', 'user' ); + + $this->assertTrue( $this->is_allowed( $editor, 'GET', $id ), 'editor GET user task' ); + $this->assertTrue( $this->is_allowed( $editor, 'POST', $id ), 'editor update user task' ); + $this->assertTrue( $this->is_allowed( $editor, 'DELETE', $id ), 'editor delete user task' ); + } + + /** + * An editor cannot touch an admin-only task (update-core needs update_core). + * + * @return void + */ + public function test_editor_blocked_from_admin_only_task() { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + $id = $this->make_task( 'update-core-task', 'update-core' ); + + $this->assertFalse( $this->is_allowed( $editor, 'GET', $id ), 'editor GET update-core' ); + $this->assertFalse( $this->is_allowed( $editor, 'POST', $id ), 'editor update update-core' ); + $this->assertFalse( $this->is_allowed( $editor, 'DELETE', $id ), 'editor delete update-core' ); + } + + /** + * An administrator can manage an admin-only task. + * + * @return void + */ + public function test_admin_can_manage_admin_only_task() { + $admin = self::factory()->user->create( [ 'role' => 'administrator' ] ); + // On multisite, `update_core` is reserved for super admins. + if ( \is_multisite() ) { + \grant_super_admin( $admin ); + } + $id = $this->make_task( 'update-core-task-2', 'update-core' ); + + $this->assertTrue( $this->is_allowed( $admin, 'DELETE', $id ), 'admin delete update-core' ); + } +} diff --git a/tests/phpunit/test-class-rest-recommendations-provider-param.php b/tests/phpunit/test-class-rest-recommendations-provider-param.php new file mode 100644 index 000000000..a29bb0fc2 --- /dev/null +++ b/tests/phpunit/test-class-rest-recommendations-provider-param.php @@ -0,0 +1,91 @@ + $query_params The query params. + * + * @return \WP_REST_Response + */ + private function query_as_editor( $query_params ) { + $editor = self::factory()->user->create( [ 'role' => 'editor' ] ); + \wp_set_current_user( $editor ); + + $request = new \WP_REST_Request( 'GET', '/wp/v2/prpl_recommendations' ); + $request->set_query_params( $query_params ); + return \rest_do_request( $request ); + } + + /** + * An array `provider[]` param must not throw a TypeError (was HTTP 500). + * + * @return void + */ + public function test_provider_array_does_not_error() { + $response = $this->query_as_editor( [ 'provider' => [ 'content-update', 'core-update' ] ] ); + $this->assertNotSame( 500, $response->get_status(), 'array provider param must not 500' ); + $this->assertSame( 200, $response->get_status() ); + } + + /** + * An array `exclude_provider[]` param must not throw a TypeError. + * + * @return void + */ + public function test_exclude_provider_array_does_not_error() { + $response = $this->query_as_editor( [ 'exclude_provider' => [ 'content-update' ] ] ); + $this->assertNotSame( 500, $response->get_status(), 'array exclude_provider param must not 500' ); + $this->assertSame( 200, $response->get_status() ); + } + + /** + * The string form (`?provider=a,b`) still works. + * + * @return void + */ + public function test_provider_string_still_works() { + $response = $this->query_as_editor( [ 'provider' => 'content-update,core-update' ] ); + $this->assertSame( 200, $response->get_status() ); + } +} diff --git a/tests/phpunit/test-class-set-page-task-guard.php b/tests/phpunit/test-class-set-page-task-guard.php new file mode 100644 index 000000000..eb0b77fc5 --- /dev/null +++ b/tests/phpunit/test-class-set-page-task-guard.php @@ -0,0 +1,128 @@ +user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $admin ); + + $_REQUEST['nonce'] = \wp_create_nonce( 'progress_planner' ); + } + + /** + * Clean up request superglobals after each test. + * + * @return void + */ + public function tearDown(): void { + unset( $_POST, $_REQUEST ); + $_POST = []; + $_REQUEST = []; + parent::tearDown(); + } + + /** + * Run the handler with the given payload and return the decoded response. + * + * @param array $payload The POST payload (merged with nonce). + * + * @return array|null The decoded JSON response. + */ + private function get_response( $payload ) { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- test setup; the handler verifies the nonce we seeded in setUp(). + $_REQUEST = \array_merge( $_REQUEST, $payload ); + $_POST = $_REQUEST; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + \ob_start(); + try { + Set_Page_About::handle_interactive_task_specific_submit(); + } catch ( \WPAjaxDieContinueException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + // Expected: wp_send_json_* calls wp_die(). + } + + return \json_decode( $this->_last_response, true ); + } + + /** + * "I have this page" with no page selected (id = 0) is rejected. + * + * @return void + */ + public function test_yes_without_page_is_rejected() { + $response = $this->get_response( + [ + 'have_page' => 'yes', + 'id' => '0', + 'task_id' => 'set-page-about', + ] + ); + + $this->assertIsArray( $response ); + $this->assertFalse( $response['success'], 'have_page=yes with id=0 must be rejected' ); + } + + /** + * "I have this page" with a valid page id is accepted. + * + * @return void + */ + public function test_yes_with_page_is_accepted() { + $page_id = self::factory()->post->create( [ 'post_type' => 'page' ] ); + + $response = $this->get_response( + [ + 'have_page' => 'yes', + 'id' => (string) $page_id, + 'task_id' => 'set-page-about', + ] + ); + + $this->assertIsArray( $response ); + $this->assertTrue( $response['success'], 'have_page=yes with a real page id should succeed' ); + } + + /** + * "I don't have this page" completes with no page id (unchanged behaviour). + * + * @return void + */ + public function test_no_page_still_completes() { + $response = $this->get_response( + [ + 'have_page' => 'no', + 'id' => '0', + 'task_id' => 'set-page-about', + ] + ); + + $this->assertIsArray( $response ); + $this->assertTrue( $response['success'], 'have_page=no should still complete without a page' ); + } +} diff --git a/tests/phpunit/test-class-sunset-notice.php b/tests/phpunit/test-class-sunset-notice.php new file mode 100644 index 000000000..bd561065b --- /dev/null +++ b/tests/phpunit/test-class-sunset-notice.php @@ -0,0 +1,168 @@ +assertTrue( $notice->should_show() ); + } + + /** + * Test that hooks are registered on instantiation in a clean install. + * + * @return void + */ + public function test_hooks_registered_by_default() { + $notice = new Sunset_Notice(); + $this->assertNotFalse( \has_action( 'progress_planner_admin_page_header_before', [ $notice, 'the_dashboard_notice' ] ) ); + $this->assertNotFalse( \has_action( 'after_plugin_row_' . \plugin_basename( \PROGRESS_PLANNER_FILE ), [ $notice, 'the_plugin_row_notice' ] ) ); + } + + /** + * Test that hooks are not registered when the filter hides the notice. + * + * @return void + */ + public function test_hooks_not_registered_when_filtered_out() { + \add_filter( 'progress_planner_show_sunset_notice', '__return_false' ); + $notice = new Sunset_Notice(); + $this->assertFalse( $notice->should_show() ); + $this->assertFalse( \has_action( 'progress_planner_admin_page_header_before', [ $notice, 'the_dashboard_notice' ] ) ); + $this->assertFalse( \has_action( 'after_plugin_row_' . \plugin_basename( \PROGRESS_PLANNER_FILE ), [ $notice, 'the_plugin_row_notice' ] ) ); + } + + /** + * Test that the message is not empty and contains no links. + * + * @return void + */ + public function test_message_has_no_links() { + $notice = new Sunset_Notice(); + $this->assertNotEmpty( $notice->get_message() ); + $this->assertStringNotContainsString( 'get_message() ); + } + + /** + * Test the plugin-row notice output. + * + * @return void + */ + public function test_plugin_row_notice_output() { + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + if ( \is_multisite() ) { + \grant_super_admin( $user_id ); + } + \wp_set_current_user( $user_id ); + + $notice = new Sunset_Notice(); + \ob_start(); + $notice->the_plugin_row_notice( \plugin_basename( \PROGRESS_PLANNER_FILE ), [] ); + $output = \ob_get_clean(); + + $this->assertStringContainsString( 'plugin-update-tr', $output ); + $this->assertStringContainsString( 'notice-warning', $output ); + $this->assertStringContainsString( Sunset_Notice::CERTIFICATE_ACTION, $output ); + } + + /** + * Test that the certificate endpoint is registered on instantiation. + * + * @return void + */ + public function test_certificate_endpoint_registered() { + $notice = new Sunset_Notice(); + $this->assertNotFalse( \has_action( 'admin_post_' . Sunset_Notice::CERTIFICATE_ACTION, [ $notice, 'render_certificate' ] ) ); + } + + /** + * Test that get_completed_badges only returns badges with 100% progress. + * + * @return void + */ + public function test_get_completed_badges_are_complete() { + $notice = new Sunset_Notice(); + $badges = $notice->get_completed_badges(); + $this->assertIsArray( $badges ); + foreach ( $badges as $badge ) { + $this->assertInstanceOf( \Progress_Planner\Badges\Badge::class, $badge ); + $this->assertSame( 100, (int) $badge->get_progress()['progress'] ); + } + } + + /** + * Test the certificate output contains the site URL and activation year. + * + * @return void + */ + public function test_certificate_output() { + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $user_id ); + + $notice = new Sunset_Notice(); + \ob_start(); + $notice->the_certificate(); + $output = \ob_get_clean(); + + $this->assertStringContainsString( \home_url(), $output ); + $this->assertStringContainsString( \progress_planner()->get_activation_date()->format( 'Y' ), $output ); + $this->assertStringContainsString( 'prpl-certificate', $output ); + } + + /** + * Test that the certificate endpoint dies for users without capabilities. + * + * @return void + */ + public function test_certificate_requires_capability() { + \wp_set_current_user( 0 ); + + $notice = new Sunset_Notice(); + $this->expectException( \WPDieException::class ); + $notice->render_certificate(); + } + + /** + * Test that the plugin-row notice outputs nothing for users without capabilities. + * + * @return void + */ + public function test_plugin_row_notice_requires_capability() { + \wp_set_current_user( 0 ); + + $notice = new Sunset_Notice(); + \ob_start(); + $notice->the_plugin_row_notice( \plugin_basename( \PROGRESS_PLANNER_FILE ), [] ); + $output = \ob_get_clean(); + + $this->assertSame( '', $output ); + } +} diff --git a/views/setting/page-select.php b/views/setting/page-select.php index 405b703bc..c10e671f0 100644 --- a/views/setting/page-select.php +++ b/views/setting/page-select.php @@ -78,7 +78,7 @@ class="prpl-pages-item prpl-pages-item-
diff --git a/views/sunset-certificate.php b/views/sunset-certificate.php new file mode 100644 index 000000000..47b17c1ec --- /dev/null +++ b/views/sunset-certificate.php @@ -0,0 +1,216 @@ +get_admin__sunset_notice(); +$prpl_completed_badges = $prpl_sunset_notice->get_completed_badges(); +$prpl_activation_date = \progress_planner()->get_activation_date(); +$prpl_badge_svg_url = \progress_planner()->get_remote_server_root_url() . '/wp-json/progress-planner-saas/v1/badge-svg/?badge_id='; + +// Scale the badges to the available space, so the certificate always fits on a single A4 page. +$prpl_badge_count = \count( $prpl_completed_badges ); +if ( $prpl_badge_count <= 8 ) { + $prpl_badge_size = 24; // mm. +} elseif ( $prpl_badge_count <= 18 ) { + $prpl_badge_size = 18; +} elseif ( $prpl_badge_count <= 32 ) { + $prpl_badge_size = 14; +} else { + $prpl_badge_size = 10; +} + +?> + +> + + + + <?php \esc_html_e( 'Progress Planner certificate', 'progress-planner' ); ?> + + + + + + + + diff --git a/views/sunset-notice.php b/views/sunset-notice.php new file mode 100644 index 000000000..dedd4d16f --- /dev/null +++ b/views/sunset-notice.php @@ -0,0 +1,22 @@ + +
+
+

+

get_admin__sunset_notice()->get_message() ); ?>

+

+ + + +
+