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-