From 5ee75ebab27cea5ff9739217456bfbdb3c55f871 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 14:47:05 +0530 Subject: [PATCH 01/24] Scaffold Stream Abilities API loader Add abstract Ability base class, Abilities loader with WP 6.9 + setting gating, and a new "Enable Abilities API" toggle under the existing Advanced settings section. The loader hooks wp_abilities_api_init and will register concrete abilities once they are added in subsequent commits. Falls back silently on WordPress < 6.9. --- classes/class-abilities.php | 161 ++++++++++++++++++++++++++++++++++++ classes/class-ability.php | 144 ++++++++++++++++++++++++++++++++ classes/class-plugin.php | 8 ++ classes/class-settings.php | 14 ++++ 4 files changed, 327 insertions(+) create mode 100644 classes/class-abilities.php create mode 100644 classes/class-ability.php diff --git a/classes/class-abilities.php b/classes/class-abilities.php new file mode 100644 index 000000000..222d90f43 --- /dev/null +++ b/classes/class-abilities.php @@ -0,0 +1,161 @@ + + */ + public $abilities = array(); + + /** + * Class constructor. + * + * @param Plugin $plugin Instance of plugin object. + */ + public function __construct( Plugin $plugin ) { + $this->plugin = $plugin; + + if ( ! $this->is_available() ) { + return; + } + + if ( ! $this->is_enabled() ) { + return; + } + + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + } + + /** + * Whether the WordPress Abilities API is available (WP 6.9+). + * + * @return bool + */ + public function is_available() { + return class_exists( '\WP_Ability' ); + } + + /** + * Whether the integration is enabled in Stream settings. + * + * @return bool + */ + public function is_enabled() { + $key = 'advanced_' . self::SETTING_NAME; + $options = isset( $this->plugin->settings ) ? (array) $this->plugin->settings->options : array(); + + return ! empty( $options[ $key ] ); + } + + /** + * List of ability slugs to load. Each maps to abilities/class-ability-{slug}.php + * and class WP_Stream\Ability_{Slug_With_Underscores}. + * + * @return array + */ + public function get_ability_slugs() { + return array( + // Read-only. + 'get-records', + 'get-record', + 'get-settings', + 'get-alerts', + 'get-connectors', + 'get-exclusion-rules', + + // Write. + 'create-alert', + 'update-settings', + 'create-exclusion-rule', + + // Destructive. + 'purge-records', + 'delete-alert', + ); + } + + /** + * Require ability files and instantiate their classes. + * + * @return void + */ + public function load_abilities() { + $dir = trailingslashit( $this->plugin->locations['dir'] ) . 'abilities/'; + + foreach ( $this->get_ability_slugs() as $slug ) { + $file = $dir . 'class-ability-' . $slug . '.php'; + if ( ! is_readable( $file ) ) { + continue; + } + include_once $file; + + $class_part = implode( '_', array_map( 'ucfirst', explode( '-', $slug ) ) ); + $class = '\WP_Stream\Ability_' . $class_part; + if ( ! class_exists( $class ) ) { + continue; + } + + $ability = new $class( $this->plugin ); + if ( ! $ability instanceof Ability ) { + continue; + } + + $this->abilities[ $ability->get_name() ] = $ability; + } + } + + /** + * Hooked to wp_abilities_api_init. Loads and registers all abilities. + * + * @return void + */ + public function register_abilities() { + if ( empty( $this->abilities ) ) { + $this->load_abilities(); + } + + foreach ( $this->abilities as $ability ) { + $ability->register(); + } + } +} diff --git a/classes/class-ability.php b/classes/class-ability.php new file mode 100644 index 000000000..548b60ff2 --- /dev/null +++ b/classes/class-ability.php @@ -0,0 +1,144 @@ +plugin = $plugin; + } + + /** + * Namespaced ability name (e.g. "stream/get-records"). + * + * @return string + */ + abstract public function get_name(); + + /** + * Short human-readable label. + * + * @return string + */ + abstract public function get_label(); + + /** + * Description of what the ability does. + * + * @return string + */ + abstract public function get_description(); + + /** + * JSON Schema for ability input. Return an empty array for no input. + * + * @return array + */ + abstract public function get_input_schema(); + + /** + * JSON Schema for ability output. + * + * @return array + */ + abstract public function get_output_schema(); + + /** + * Execute the ability. + * + * @param array $input Validated input matching get_input_schema(). + * @return mixed|\WP_Error Result conforming to get_output_schema(), or WP_Error. + */ + abstract public function execute( $input ); + + /** + * Permission check. Defaults to manage_options; override per ability. + * + * @param array $input Input that will be passed to execute(). + * @return bool|\WP_Error + */ + public function permission_callback( $input = array() ) { + unset( $input ); + return current_user_can( 'manage_options' ); + } + + /** + * Annotation flags for the ability (readonly, destructive, idempotent). + * + * @return array + */ + public function get_annotations() { + return array(); + } + + /** + * Meta passed to wp_register_ability(). Sets category and REST exposure. + * + * @return array + */ + public function get_meta() { + $meta = array( + 'category' => Abilities::CATEGORY_SLUG, + 'show_in_rest' => true, + ); + + $annotations = $this->get_annotations(); + if ( ! empty( $annotations ) ) { + $meta['annotations'] = $annotations; + } + + return $meta; + } + + /** + * Register the ability with the Abilities API. + * + * @return void + */ + final public function register() { + if ( ! function_exists( 'wp_register_ability' ) ) { + return; + } + + $args = array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'output_schema' => $this->get_output_schema(), + 'execute_callback' => array( $this, 'execute' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'meta' => $this->get_meta(), + ); + + $input_schema = $this->get_input_schema(); + if ( ! empty( $input_schema ) ) { + $args['input_schema'] = $input_schema; + } + + wp_register_ability( $this->get_name(), $args ); + } +} diff --git a/classes/class-plugin.php b/classes/class-plugin.php index 651a45934..f8c9e0fe1 100755 --- a/classes/class-plugin.php +++ b/classes/class-plugin.php @@ -72,6 +72,13 @@ class Plugin { */ public $alerts_list; + /** + * Holds and manages WordPress Abilities API integration. + * + * @var Abilities + */ + public $abilities; + /** * Holds and manages connectors * @@ -246,6 +253,7 @@ public function init() { $this->connectors = new Connectors( $this ); $this->alerts = new Alerts( $this ); $this->alerts_list = new Alerts_List( $this ); + $this->abilities = new Abilities( $this ); } /** diff --git a/classes/class-settings.php b/classes/class-settings.php index 28eb9f601..422d694f4 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -400,6 +400,20 @@ public function get_fields() { array_push( $fields['advanced']['fields'], $wp_cron_tracking ); + // Abilities API toggle is only meaningful on WordPress 6.9+. + if ( class_exists( '\WP_Ability' ) ) { + $enable_abilities_api = array( + 'name' => 'enable_abilities_api', + 'title' => esc_html__( 'Enable Abilities API', 'stream' ), + 'type' => 'checkbox', + 'desc' => esc_html__( 'Expose Stream operations via the WordPress Abilities API. Allows AI agents and automation tools to query records, manage alerts, and update settings through /wp-abilities/v1/stream/* REST endpoints. Requires WordPress 6.9 or newer.', 'stream' ), + 'after_field' => esc_html__( 'Enabled', 'stream' ), + 'default' => 0, + ); + + array_push( $fields['advanced']['fields'], $enable_abilities_api ); + } + /** * Filter allows for modification of options fields * From f3fa8ea06275e8fdeffd9f11669d72abba266571 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 14:51:57 +0530 Subject: [PATCH 02/24] Add read-only Stream abilities Implements the six read-only abilities under stream/* namespace: get-records, get-record, get-settings, get-alerts, get-connectors, and get-exclusion-rules. Each ability has hand-written JSON Schemas, delegates to existing Stream APIs, and ships with a PHPUnit test covering name, schema, permission gating, and execution. Tests skip themselves on WordPress < 6.9 via the shared Abilities_TestCase base class. --- abilities/class-ability-get-alerts.php | 129 ++++++++++ abilities/class-ability-get-connectors.php | 107 ++++++++ .../class-ability-get-exclusion-rules.php | 90 +++++++ abilities/class-ability-get-record.php | 113 +++++++++ abilities/class-ability-get-records.php | 239 ++++++++++++++++++ abilities/class-ability-get-settings.php | 72 ++++++ phpunit-multisite.xml | 1 + phpunit.xml | 1 + tests/bootstrap.php | 1 + .../phpunit/abilities/abilities-testcase.php | 76 ++++++ .../test-class-ability-get-alerts.php | 91 +++++++ .../test-class-ability-get-connectors.php | 71 ++++++ ...test-class-ability-get-exclusion-rules.php | 76 ++++++ .../test-class-ability-get-record.php | 82 ++++++ .../test-class-ability-get-records.php | 119 +++++++++ .../test-class-ability-get-settings.php | 58 +++++ 16 files changed, 1326 insertions(+) create mode 100644 abilities/class-ability-get-alerts.php create mode 100644 abilities/class-ability-get-connectors.php create mode 100644 abilities/class-ability-get-exclusion-rules.php create mode 100644 abilities/class-ability-get-record.php create mode 100644 abilities/class-ability-get-records.php create mode 100644 abilities/class-ability-get-settings.php create mode 100644 tests/phpunit/abilities/abilities-testcase.php create mode 100644 tests/phpunit/abilities/test-class-ability-get-alerts.php create mode 100644 tests/phpunit/abilities/test-class-ability-get-connectors.php create mode 100644 tests/phpunit/abilities/test-class-ability-get-exclusion-rules.php create mode 100644 tests/phpunit/abilities/test-class-ability-get-record.php create mode 100644 tests/phpunit/abilities/test-class-ability-get-records.php create mode 100644 tests/phpunit/abilities/test-class-ability-get-settings.php diff --git a/abilities/class-ability-get-alerts.php b/abilities/class-ability-get-alerts.php new file mode 100644 index 000000000..1e601a83d --- /dev/null +++ b/abilities/class-ability-get-alerts.php @@ -0,0 +1,129 @@ + true, + 'idempotent' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'description' => 'Filter by alert status.', + 'enum' => array( 'enabled', 'disabled', 'any' ), + 'default' => 'any', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'array', + 'description' => 'Configured alert rules.', + 'items' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'status' => array( + 'type' => 'string', + 'enum' => array( 'wp_stream_enabled', 'wp_stream_disabled' ), + ), + 'title' => array( 'type' => 'string' ), + 'alert_type' => array( 'type' => array( 'string', 'null' ) ), + 'alert_meta' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + $requested = isset( $input['status'] ) ? $input['status'] : 'any'; + + switch ( $requested ) { + case 'enabled': + $statuses = array( 'wp_stream_enabled' ); + break; + case 'disabled': + $statuses = array( 'wp_stream_disabled' ); + break; + default: + $statuses = array( 'wp_stream_enabled', 'wp_stream_disabled' ); + } + + $posts = get_posts( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => $statuses, + 'posts_per_page' => -1, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page + ) + ); + + $out = array(); + foreach ( $posts as $post ) { + $out[] = array( + 'id' => (int) $post->ID, + 'status' => (string) $post->post_status, + 'title' => (string) $post->post_title, + 'alert_type' => get_post_meta( $post->ID, 'alert_type', true ), + 'alert_meta' => (array) get_post_meta( $post->ID, 'alert_meta', true ), + ); + } + + return $out; + } +} diff --git a/abilities/class-ability-get-connectors.php b/abilities/class-ability-get-connectors.php new file mode 100644 index 000000000..574370f38 --- /dev/null +++ b/abilities/class-ability-get-connectors.php @@ -0,0 +1,107 @@ + true, + 'idempotent' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array(); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'array', + 'description' => 'Registered connectors.', + 'items' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'slug' => array( + 'type' => 'string', + 'description' => 'Connector slug.', + ), + 'label' => array( + 'type' => 'string', + 'description' => 'Localized connector label.', + ), + 'contexts' => array( + 'type' => 'object', + 'description' => 'Map of context slug to label.', + 'additionalProperties' => array( 'type' => 'string' ), + ), + 'actions' => array( + 'type' => 'object', + 'description' => 'Map of action slug to label.', + 'additionalProperties' => array( 'type' => 'string' ), + ), + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + unset( $input ); + + $out = array(); + $connectors = isset( $this->plugin->connectors->connectors ) ? (array) $this->plugin->connectors->connectors : array(); + + foreach ( $connectors as $slug => $connector ) { + $out[] = array( + 'slug' => (string) $slug, + 'label' => method_exists( $connector, 'get_label' ) ? (string) $connector->get_label() : (string) $slug, + 'contexts' => method_exists( $connector, 'get_context_labels' ) ? (array) $connector->get_context_labels() : array(), + 'actions' => method_exists( $connector, 'get_action_labels' ) ? (array) $connector->get_action_labels() : array(), + ); + } + + return $out; + } +} diff --git a/abilities/class-ability-get-exclusion-rules.php b/abilities/class-ability-get-exclusion-rules.php new file mode 100644 index 000000000..a864d9d2e --- /dev/null +++ b/abilities/class-ability-get-exclusion-rules.php @@ -0,0 +1,90 @@ + true, + 'idempotent' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array(); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'array', + 'description' => 'Exclusion rules. Each entry is an object with optional author_or_role, connector, context, action, and ip_address fields. Stream stores rules as parallel arrays keyed by index.', + 'items' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + unset( $input ); + + $options = (array) $this->plugin->settings->options; + $rules = isset( $options['exclude_rules'] ) ? (array) $options['exclude_rules'] : array(); + + // Reuse Log's row pivot so output matches how Stream applies the rules internally. + $rows = $this->plugin->log->exclude_rules_by_rows( $rules ); + + // Drop the internal exclude_row marker from the output. + $out = array(); + foreach ( $rows as $row ) { + if ( is_array( $row ) ) { + unset( $row['exclude_row'] ); + } + $out[] = $row; + } + + return array_values( $out ); + } +} diff --git a/abilities/class-ability-get-record.php b/abilities/class-ability-get-record.php new file mode 100644 index 000000000..a063a329d --- /dev/null +++ b/abilities/class-ability-get-record.php @@ -0,0 +1,113 @@ + true, + 'idempotent' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => 'Stream record ID.', + 'minimum' => 1, + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => true, + 'properties' => array( + 'ID' => array( 'type' => 'integer' ), + 'site_id' => array( 'type' => 'integer' ), + 'blog_id' => array( 'type' => 'integer' ), + 'object_id' => array( 'type' => array( 'integer', 'null' ) ), + 'user_id' => array( 'type' => 'integer' ), + 'user_role' => array( 'type' => 'string' ), + 'summary' => array( 'type' => 'string' ), + 'created' => array( 'type' => 'string' ), + 'connector' => array( 'type' => 'string' ), + 'context' => array( 'type' => 'string' ), + 'action' => array( 'type' => 'string' ), + 'ip' => array( 'type' => array( 'string', 'null' ) ), + 'meta' => array( + 'type' => 'object', + 'description' => 'Per-record metadata key/value pairs.', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + $id = isset( $input['id'] ) ? (int) $input['id'] : 0; + + $records = $this->plugin->db->get_records( array( 'record' => $id ) ); + + if ( empty( $records ) ) { + return new \WP_Error( + 'stream_record_not_found', + __( 'Record not found.', 'stream' ), + array( 'status' => 404 ) + ); + } + + $record = (array) $records[0]; + $record['meta'] = (array) get_metadata( 'record', $record['ID'] ); + + return $record; + } +} diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php new file mode 100644 index 000000000..e0140e42c --- /dev/null +++ b/abilities/class-ability-get-records.php @@ -0,0 +1,239 @@ + true, + 'idempotent' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => 'Free-text search term.', + ), + 'search_field' => array( + 'type' => 'string', + 'description' => 'Column to search against. Defaults to summary.', + 'enum' => array( 'summary', 'ip' ), + ), + 'date_from' => array( + 'type' => 'string', + 'description' => 'Inclusive lower bound, YYYY-MM-DD.', + 'format' => 'date', + ), + 'date_to' => array( + 'type' => 'string', + 'description' => 'Inclusive upper bound, YYYY-MM-DD.', + 'format' => 'date', + ), + 'user_id' => array( + 'type' => 'integer', + 'description' => 'WordPress user ID. 0 matches system actions.', + ), + 'user_id__in' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'description' => 'Match any of these user IDs.', + ), + 'user_role' => array( + 'type' => 'string', + 'description' => 'WordPress role slug.', + ), + 'connector' => array( + 'type' => 'string', + 'description' => 'Connector slug (e.g. posts, users).', + ), + 'connector__in' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => 'Match any of these connector slugs.', + ), + 'context' => array( + 'type' => 'string', + 'description' => 'Context slug.', + ), + 'action' => array( + 'type' => 'string', + 'description' => 'Action slug (created, updated, deleted, etc.).', + ), + 'ip' => array( + 'type' => 'string', + 'description' => 'IP address to filter by.', + ), + 'object_id' => array( + 'type' => 'integer', + 'description' => 'ID of the object the record refers to.', + ), + 'records_per_page' => array( + 'type' => 'integer', + 'description' => 'Page size (1-' . self::MAX_PER_PAGE . ').', + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + 'default' => self::DEFAULT_PER_PAGE, + ), + 'paged' => array( + 'type' => 'integer', + 'description' => 'Page number (1-indexed).', + 'minimum' => 1, + 'default' => 1, + ), + 'order' => array( + 'type' => 'string', + 'description' => 'Sort direction.', + 'enum' => array( 'asc', 'desc', 'ASC', 'DESC' ), + 'default' => 'desc', + ), + 'orderby' => array( + 'type' => 'string', + 'description' => 'Column to order by.', + 'default' => 'date', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'records' => array( + 'type' => 'array', + 'description' => 'Matching log records.', + 'items' => array( + 'type' => 'object', + 'additionalProperties' => true, + 'properties' => array( + 'ID' => array( 'type' => 'integer' ), + 'site_id' => array( 'type' => 'integer' ), + 'blog_id' => array( 'type' => 'integer' ), + 'object_id' => array( 'type' => array( 'integer', 'null' ) ), + 'user_id' => array( 'type' => 'integer' ), + 'user_role' => array( 'type' => 'string' ), + 'summary' => array( 'type' => 'string' ), + 'created' => array( 'type' => 'string' ), + 'connector' => array( 'type' => 'string' ), + 'context' => array( 'type' => 'string' ), + 'action' => array( 'type' => 'string' ), + 'ip' => array( 'type' => array( 'string', 'null' ) ), + ), + ), + ), + 'total' => array( + 'type' => 'integer', + 'description' => 'Total matching records, ignoring pagination.', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + $input = (array) $input; + + $allowed_keys = array( + 'search', + 'search_field', + 'date_from', + 'date_to', + 'user_id', + 'user_id__in', + 'user_role', + 'connector', + 'connector__in', + 'context', + 'action', + 'ip', + 'object_id', + 'records_per_page', + 'paged', + 'order', + 'orderby', + ); + + $args = array_intersect_key( $input, array_flip( $allowed_keys ) ); + + if ( ! isset( $args['records_per_page'] ) ) { + $args['records_per_page'] = self::DEFAULT_PER_PAGE; + } + + $records = $this->plugin->db->get_records( $args ); + $total = $this->plugin->db->get_found_records_count(); + + // Records are stdClass objects; convert to arrays for schema-friendly output. + $records = array_map( + static function ( $record ) { + return (array) $record; + }, + $records + ); + + return array( + 'records' => $records, + 'total' => (int) $total, + ); + } +} diff --git a/abilities/class-ability-get-settings.php b/abilities/class-ability-get-settings.php new file mode 100644 index 000000000..ccc14fe04 --- /dev/null +++ b/abilities/class-ability-get-settings.php @@ -0,0 +1,72 @@ + true, + 'idempotent' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array(); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Settings keyed by {section}_{field} (e.g. general_records_ttl, advanced_enable_abilities_api).', + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + unset( $input ); + + return (array) $this->plugin->settings->options; + } +} diff --git a/phpunit-multisite.xml b/phpunit-multisite.xml index 6169376bc..e1d1a56ff 100644 --- a/phpunit-multisite.xml +++ b/phpunit-multisite.xml @@ -12,6 +12,7 @@ > + abilities alerts classes connectors diff --git a/phpunit.xml b/phpunit.xml index 990e46c67..7bf1f4e6e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -12,6 +12,7 @@ > + abilities alerts classes connectors diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0c5cdf038..c02547751 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -120,3 +120,4 @@ function () { // Base class for future tests require __DIR__ . '/phpunit/test-class-alert-trigger.php'; +require __DIR__ . '/phpunit/abilities/abilities-testcase.php'; diff --git a/tests/phpunit/abilities/abilities-testcase.php b/tests/phpunit/abilities/abilities-testcase.php new file mode 100644 index 000000000..db1cc9081 --- /dev/null +++ b/tests/phpunit/abilities/abilities-testcase.php @@ -0,0 +1,76 @@ +markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); + } + + $this->admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $this->subscriber_user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + } + + /** + * Validate a value against a JSON Schema using WP's REST validator. + * + * Asserts no validation errors. Returns the validation result (true on success). + * + * @param mixed $value Value to validate. + * @param array $schema JSON Schema. + * @param string $msg Optional message for assertion failure. + * @return mixed + */ + protected function assert_matches_schema( $value, $schema, $msg = '' ) { + if ( empty( $schema ) ) { + return true; + } + + $result = rest_validate_value_from_schema( $value, $schema ); + + if ( is_wp_error( $result ) ) { + $this->fail( + ( $msg ? $msg . ': ' : '' ) + . 'Value did not match schema: ' + . $result->get_error_message() + ); + } + + $this->assertNotInstanceOf( '\WP_Error', $result ); + return $result; + } +} diff --git a/tests/phpunit/abilities/test-class-ability-get-alerts.php b/tests/phpunit/abilities/test-class-ability-get-alerts.php new file mode 100644 index 000000000..8ada6886b --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-get-alerts.php @@ -0,0 +1,91 @@ +plugin->locations['dir'] . 'abilities/class-ability-get-alerts.php'; + $this->ability = new Ability_Get_Alerts( $this->plugin ); + } + + public function test_name_and_schema() { + $this->assertSame( 'stream/get-alerts', $this->ability->get_name() ); + + $input = $this->ability->get_input_schema(); + $this->assertSame( array( 'enabled', 'disabled', 'any' ), $input['properties']['status']['enum'] ); + + $output = $this->ability->get_output_schema(); + $this->assertSame( 'array', $output['type'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_returns_seeded_alerts() { + wp_set_current_user( $this->admin_user_id ); + + $enabled_id = wp_insert_post( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => 'wp_stream_enabled', + 'post_title' => 'Enabled alert', + ) + ); + update_post_meta( $enabled_id, 'alert_type', 'highlight' ); + update_post_meta( + $enabled_id, + 'alert_meta', + array( + 'trigger_author' => 'any', + 'trigger_context' => 'any', + 'trigger_action' => 'any', + ) + ); + + $disabled_id = wp_insert_post( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => 'wp_stream_disabled', + 'post_title' => 'Disabled alert', + ) + ); + update_post_meta( $disabled_id, 'alert_type', 'email' ); + + $all = $this->ability->execute( array( 'status' => 'any' ) ); + $enabled = $this->ability->execute( array( 'status' => 'enabled' ) ); + $disabled = $this->ability->execute( array( 'status' => 'disabled' ) ); + + $this->assertCount( 2, $all ); + $this->assertCount( 1, $enabled ); + $this->assertCount( 1, $disabled ); + $this->assertSame( $enabled_id, $enabled[0]['id'] ); + $this->assertSame( 'highlight', $enabled[0]['alert_type'] ); + $this->assertSame( $disabled_id, $disabled[0]['id'] ); + } +} diff --git a/tests/phpunit/abilities/test-class-ability-get-connectors.php b/tests/phpunit/abilities/test-class-ability-get-connectors.php new file mode 100644 index 000000000..71f8f15f1 --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-get-connectors.php @@ -0,0 +1,71 @@ +plugin->locations['dir'] . 'abilities/class-ability-get-connectors.php'; + $this->ability = new Ability_Get_Connectors( $this->plugin ); + } + + public function test_name_and_schema() { + $this->assertSame( 'stream/get-connectors', $this->ability->get_name() ); + $this->assertSame( array(), $this->ability->get_input_schema() ); + + $output = $this->ability->get_output_schema(); + $this->assertSame( 'array', $output['type'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_returns_registered_connectors() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( array() ); + + $this->assertIsArray( $result ); + $this->assertNotEmpty( $result, 'Expected at least one registered connector.' ); + + $slugs = array_column( $result, 'slug' ); + $this->assertContains( 'posts', $slugs, 'The built-in posts connector should be registered.' ); + + // Each entry has the expected shape. + foreach ( $result as $entry ) { + $this->assertArrayHasKey( 'slug', $entry ); + $this->assertArrayHasKey( 'label', $entry ); + $this->assertArrayHasKey( 'contexts', $entry ); + $this->assertArrayHasKey( 'actions', $entry ); + $this->assertIsString( $entry['slug'] ); + $this->assertIsString( $entry['label'] ); + $this->assertIsArray( $entry['contexts'] ); + $this->assertIsArray( $entry['actions'] ); + } + } +} diff --git a/tests/phpunit/abilities/test-class-ability-get-exclusion-rules.php b/tests/phpunit/abilities/test-class-ability-get-exclusion-rules.php new file mode 100644 index 000000000..51b8f18e0 --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-get-exclusion-rules.php @@ -0,0 +1,76 @@ +plugin->locations['dir'] . 'abilities/class-ability-get-exclusion-rules.php'; + $this->ability = new Ability_Get_Exclusion_Rules( $this->plugin ); + } + + public function test_name_and_schema() { + $this->assertSame( 'stream/get-exclusion-rules', $this->ability->get_name() ); + $this->assertSame( array(), $this->ability->get_input_schema() ); + + $output = $this->ability->get_output_schema(); + $this->assertSame( 'array', $output['type'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_returns_empty_array_when_no_rules_configured() { + wp_set_current_user( $this->admin_user_id ); + $result = $this->ability->execute( array() ); + $this->assertSame( array(), $result ); + } + + public function test_returns_pivoted_rule_rows() { + wp_set_current_user( $this->admin_user_id ); + + // Inject a couple of rules in the parallel-array shape Stream uses. + $this->plugin->settings->options['exclude_rules'] = array( + 'exclude_row' => array( 'rule-a', 'rule-b' ), + 'author_or_role' => array( 'administrator', '0' ), + 'connector' => array( 'posts', 'users' ), + 'context' => array( 'post', '' ), + 'action' => array( 'updated', '' ), + 'ip_address' => array( '', '127.0.0.1' ), + ); + + $result = $this->ability->execute( array() ); + + $this->assertCount( 2, $result ); + $this->assertSame( 'administrator', $result[0]['author_or_role'] ); + $this->assertSame( 'posts', $result[0]['connector'] ); + $this->assertSame( '127.0.0.1', $result[1]['ip_address'] ); + // exclude_row is internal — should not appear in output. + $this->assertArrayNotHasKey( 'exclude_row', $result[0] ); + } +} diff --git a/tests/phpunit/abilities/test-class-ability-get-record.php b/tests/phpunit/abilities/test-class-ability-get-record.php new file mode 100644 index 000000000..aad1c71a6 --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-get-record.php @@ -0,0 +1,82 @@ +plugin->locations['dir'] . 'abilities/class-ability-get-record.php'; + $this->ability = new Ability_Get_Record( $this->plugin ); + } + + public function test_name_and_schemas() { + $this->assertSame( 'stream/get-record', $this->ability->get_name() ); + + $input = $this->ability->get_input_schema(); + $this->assertSame( array( 'id' ), $input['required'] ); + + $output = $this->ability->get_output_schema(); + $this->assertSame( 'object', $output['type'] ); + $this->assertArrayHasKey( 'meta', $output['properties'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_returns_wp_error_when_not_found() { + wp_set_current_user( $this->admin_user_id ); + $result = $this->ability->execute( array( 'id' => PHP_INT_MAX ) ); + + $this->assertInstanceOf( '\WP_Error', $result ); + $this->assertSame( 'stream_record_not_found', $result->get_error_code() ); + } + + public function test_returns_record_with_meta_when_found() { + wp_set_current_user( $this->admin_user_id ); + + $this->plugin->log->log( + 'users', + 'Single record fetch', + array(), + 0, + 'users', + 'created' + ); + + $records = $this->plugin->db->get_records( array( 'records_per_page' => 1 ) ); + $this->assertNotEmpty( $records ); + $id = (int) $records[0]->ID; + + $result = $this->ability->execute( array( 'id' => $id ) ); + + $this->assertIsArray( $result ); + $this->assertSame( $id, (int) $result['ID'] ); + $this->assertArrayHasKey( 'meta', $result ); + $this->assertIsArray( $result['meta'] ); + } +} diff --git a/tests/phpunit/abilities/test-class-ability-get-records.php b/tests/phpunit/abilities/test-class-ability-get-records.php new file mode 100644 index 000000000..55acdc9a6 --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-get-records.php @@ -0,0 +1,119 @@ +plugin->locations['dir'] . 'abilities/class-ability-get-records.php'; + $this->ability = new Ability_Get_Records( $this->plugin ); + } + + public function test_get_name_returns_namespaced_slug() { + $this->assertSame( 'stream/get-records', $this->ability->get_name() ); + } + + public function test_input_schema_is_object() { + $schema = $this->ability->get_input_schema(); + $this->assertIsArray( $schema ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'records_per_page', $schema['properties'] ); + } + + public function test_output_schema_is_object_with_records_and_total() { + $schema = $this->ability->get_output_schema(); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'records', $schema['properties'] ); + $this->assertArrayHasKey( 'total', $schema['properties'] ); + } + + public function test_meta_marks_readonly_idempotent_and_rest_exposed() { + $meta = $this->ability->get_meta(); + $this->assertTrue( $meta['show_in_rest'] ); + $this->assertSame( 'stream', $meta['category'] ); + $this->assertTrue( $meta['annotations']['readonly'] ); + $this->assertTrue( $meta['annotations']['idempotent'] ); + } + + public function test_permission_denied_for_subscriber() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback( array() ) ); + } + + public function test_permission_granted_for_admin() { + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback( array() ) ); + } + + public function test_execute_returns_records_and_total() { + // Seed a record via the Stream log. + wp_set_current_user( $this->admin_user_id ); + $this->plugin->log->log( + 'users', + 'Test summary for ability', + array(), + 0, + 'users', + 'created' + ); + + $result = $this->ability->execute( array( 'records_per_page' => 5 ) ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'records', $result ); + $this->assertArrayHasKey( 'total', $result ); + $this->assertIsInt( $result['total'] ); + $this->assertIsArray( $result['records'] ); + $this->assertGreaterThanOrEqual( 1, $result['total'] ); + } + + public function test_execute_output_validates_against_schema() { + wp_set_current_user( $this->admin_user_id ); + $this->plugin->log->log( + 'users', + 'Schema validation summary', + array(), + 0, + 'users', + 'updated' + ); + + $result = $this->ability->execute( array( 'records_per_page' => 5 ) ); + $this->assert_matches_schema( $result, $this->ability->get_output_schema() ); + } + + public function test_execute_strips_unknown_input_keys() { + wp_set_current_user( $this->admin_user_id ); + + // Unknown keys should be ignored, not passed through to DB::get_records(). + $result = $this->ability->execute( + array( + 'records_per_page' => 1, + 'totally_made_up' => 'value', + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'records', $result ); + } +} diff --git a/tests/phpunit/abilities/test-class-ability-get-settings.php b/tests/phpunit/abilities/test-class-ability-get-settings.php new file mode 100644 index 000000000..48ffcaf10 --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-get-settings.php @@ -0,0 +1,58 @@ +plugin->locations['dir'] . 'abilities/class-ability-get-settings.php'; + $this->ability = new Ability_Get_Settings( $this->plugin ); + } + + public function test_name_and_schema() { + $this->assertSame( 'stream/get-settings', $this->ability->get_name() ); + $this->assertSame( array(), $this->ability->get_input_schema() ); + + $output = $this->ability->get_output_schema(); + $this->assertSame( 'object', $output['type'] ); + $this->assertTrue( $output['additionalProperties'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_execute_returns_settings_array() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( array() ); + + $this->assertIsArray( $result ); + // Stream settings always include general_records_ttl with a default. + $this->assertArrayHasKey( 'general_records_ttl', $result ); + } +} From 632bfda3dd8cad77aefcb56be0a6db9e1db0412e Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 14:55:54 +0530 Subject: [PATCH 03/24] Add Stream write abilities Add three abilities that mutate Stream state through the existing internal APIs: stream/create-alert (creates a wp_stream_alerts CPT post with alert_type and alert_meta), stream/update-settings (partial-merge update to the wp_stream option), and stream/create-exclusion-rule (appends to the parallel-array exclude_rules option columns Stream already uses). Each ability is gated behind the manage_options capability. Hand-written JSON schemas describe inputs and outputs for AI consumers; create-alert requires the four trigger fields, create-exclusion-rule requires at least one filter property, and update-settings requires a non-empty settings map. Each ability ships with PHPUnit coverage that verifies permissions, schema shape, and end-to-end execution against the option or post store. --- abilities/class-ability-create-alert.php | 148 ++++++++++++++++ .../class-ability-create-exclusion-rule.php | 160 ++++++++++++++++++ abilities/class-ability-update-settings.php | 92 ++++++++++ .../test-class-ability-create-alert.php | 100 +++++++++++ ...st-class-ability-create-exclusion-rule.php | 91 ++++++++++ .../test-class-ability-update-settings.php | 90 ++++++++++ 6 files changed, 681 insertions(+) create mode 100644 abilities/class-ability-create-alert.php create mode 100644 abilities/class-ability-create-exclusion-rule.php create mode 100644 abilities/class-ability-update-settings.php create mode 100644 tests/phpunit/abilities/test-class-ability-create-alert.php create mode 100644 tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php create mode 100644 tests/phpunit/abilities/test-class-ability-update-settings.php diff --git a/abilities/class-ability-create-alert.php b/abilities/class-ability-create-alert.php new file mode 100644 index 000000000..43a1303ad --- /dev/null +++ b/abilities/class-ability-create-alert.php @@ -0,0 +1,148 @@ + false, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'alert_type', 'trigger_author', 'trigger_context', 'trigger_action' ), + 'properties' => array( + 'alert_type' => array( + 'type' => 'string', + 'description' => 'Notifier slug. Built-in types are none, highlight, email, ifttt, slack. Other slugs may be registered by extensions.', + ), + 'trigger_author' => array( + 'type' => 'string', + 'description' => 'User ID or role slug to match. Use "any" to match all authors.', + ), + 'trigger_context' => array( + 'type' => 'string', + 'description' => 'Connector or "connector-context" slug. Use "any" to match all contexts.', + ), + 'trigger_action' => array( + 'type' => 'string', + 'description' => 'Action slug to match (e.g. "updated"). Use "any" to match all actions.', + ), + 'alert_meta' => array( + 'type' => 'object', + 'description' => 'Additional notifier-specific configuration (e.g. email recipients, slack webhook).', + 'additionalProperties' => true, + ), + 'status' => array( + 'type' => 'string', + 'description' => 'Initial alert status.', + 'enum' => array( 'wp_stream_enabled', 'wp_stream_disabled' ), + 'default' => 'wp_stream_enabled', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'status' => array( + 'type' => 'string', + 'enum' => array( 'wp_stream_enabled', 'wp_stream_disabled' ), + ), + 'title' => array( 'type' => 'string' ), + 'alert_type' => array( 'type' => array( 'string', 'null' ) ), + 'alert_meta' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + $status = isset( $input['status'] ) ? $input['status'] : 'wp_stream_enabled'; + + $post_id = wp_insert_post( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => $status, + 'post_title' => '', + ), + true + ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + $extra_meta = isset( $input['alert_meta'] ) ? (array) $input['alert_meta'] : array(); + $alert_meta = array_merge( + $extra_meta, + array( + 'trigger_author' => $input['trigger_author'], + 'trigger_context' => $input['trigger_context'], + 'trigger_action' => $input['trigger_action'], + ) + ); + + update_post_meta( $post_id, 'alert_type', $input['alert_type'] ); + update_post_meta( $post_id, 'alert_meta', $alert_meta ); + + return array( + 'id' => (int) $post_id, + 'status' => (string) get_post_status( $post_id ), + 'title' => (string) get_the_title( $post_id ), + 'alert_type' => get_post_meta( $post_id, 'alert_type', true ), + 'alert_meta' => (array) get_post_meta( $post_id, 'alert_meta', true ), + ); + } +} diff --git a/abilities/class-ability-create-exclusion-rule.php b/abilities/class-ability-create-exclusion-rule.php new file mode 100644 index 000000000..742d02a94 --- /dev/null +++ b/abilities/class-ability-create-exclusion-rule.php @@ -0,0 +1,160 @@ + false, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'minProperties' => 1, + 'properties' => array( + 'author_or_role' => array( + 'type' => 'string', + 'description' => 'User ID or role slug. Empty string matches anything.', + ), + 'connector' => array( + 'type' => 'string', + 'description' => 'Connector slug (e.g. "posts", "users").', + ), + 'context' => array( + 'type' => 'string', + 'description' => 'Context slug under the connector.', + ), + 'action' => array( + 'type' => 'string', + 'description' => 'Action slug (e.g. "updated", "deleted").', + ), + 'ip_address' => array( + 'type' => 'string', + 'description' => 'Client IP address (IPv4 or IPv6).', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'index' => array( + 'type' => 'integer', + 'description' => 'Zero-based position of the new rule in the exclude_rules option.', + ), + 'rule' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'author_or_role' => array( 'type' => array( 'string', 'null' ) ), + 'connector' => array( 'type' => array( 'string', 'null' ) ), + 'context' => array( 'type' => array( 'string', 'null' ) ), + 'action' => array( 'type' => array( 'string', 'null' ) ), + 'ip_address' => array( 'type' => array( 'string', 'null' ) ), + ), + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + $option_key = $this->plugin->settings->option_key; + $options = (array) get_option( $option_key, array() ); + + $rules = isset( $options['exclude_rules'] ) && is_array( $options['exclude_rules'] ) + ? $options['exclude_rules'] + : array(); + + // Ensure all parallel-array columns exist. + if ( ! isset( $rules['exclude_row'] ) || ! is_array( $rules['exclude_row'] ) ) { + $rules['exclude_row'] = array(); + } + foreach ( self::RULE_COLUMNS as $column ) { + if ( ! isset( $rules[ $column ] ) || ! is_array( $rules[ $column ] ) ) { + $rules[ $column ] = array(); + } + } + + $index = count( $rules['exclude_row'] ); + $rules['exclude_row'][ $index ] = ''; + + $rule = array(); + foreach ( self::RULE_COLUMNS as $column ) { + $value = isset( $input[ $column ] ) ? (string) $input[ $column ] : ''; + $rules[ $column ][ $index ] = $value; + $rule[ $column ] = '' === $value ? null : $value; + } + + $options['exclude_rules'] = $rules; + update_option( $option_key, $options ); + + // Refresh in-memory copy. + $this->plugin->settings->options = $options; + + return array( + 'index' => $index, + 'rule' => $rule, + ); + } +} diff --git a/abilities/class-ability-update-settings.php b/abilities/class-ability-update-settings.php new file mode 100644 index 000000000..a4c2d4a8a --- /dev/null +++ b/abilities/class-ability-update-settings.php @@ -0,0 +1,92 @@ + false, + 'idempotent' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'settings' ), + 'properties' => array( + 'settings' => array( + 'type' => 'object', + 'description' => 'Partial settings map keyed by {section}_{field}. Values overwrite existing entries; omitted keys are preserved.', + 'additionalProperties' => true, + 'minProperties' => 1, + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'description' => 'The complete settings array after the update.', + 'additionalProperties' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + $option_key = $this->plugin->settings->option_key; + $current = (array) get_option( $option_key, array() ); + $updates = isset( $input['settings'] ) ? (array) $input['settings'] : array(); + + $merged = array_merge( $current, $updates ); + update_option( $option_key, $merged ); + + // Refresh in-memory copy so subsequent abilities see the change. + $this->plugin->settings->options = $merged; + + return $merged; + } +} diff --git a/tests/phpunit/abilities/test-class-ability-create-alert.php b/tests/phpunit/abilities/test-class-ability-create-alert.php new file mode 100644 index 000000000..15a322ff2 --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-create-alert.php @@ -0,0 +1,100 @@ +plugin->locations['dir'] . 'abilities/class-ability-create-alert.php'; + $this->ability = new Ability_Create_Alert( $this->plugin ); + } + + public function test_name_and_schema_shape() { + $this->assertSame( 'stream/create-alert', $this->ability->get_name() ); + + $input = $this->ability->get_input_schema(); + $this->assertSame( 'object', $input['type'] ); + $this->assertSame( + array( 'alert_type', 'trigger_author', 'trigger_context', 'trigger_action' ), + $input['required'] + ); + + $output = $this->ability->get_output_schema(); + $this->assertSame( 'object', $output['type'] ); + $this->assertArrayHasKey( 'id', $output['properties'] ); + $this->assertArrayHasKey( 'alert_meta', $output['properties'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_creates_alert_post_and_meta() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( + array( + 'alert_type' => 'highlight', + 'trigger_author' => 'any', + 'trigger_context' => 'posts', + 'trigger_action' => 'updated', + 'alert_meta' => array( 'color' => 'yellow' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertGreaterThan( 0, $result['id'] ); + $this->assertSame( 'wp_stream_enabled', $result['status'] ); + $this->assertSame( 'highlight', $result['alert_type'] ); + + $alert_meta = (array) get_post_meta( $result['id'], 'alert_meta', true ); + $this->assertSame( 'any', $alert_meta['trigger_author'] ); + $this->assertSame( 'posts', $alert_meta['trigger_context'] ); + $this->assertSame( 'updated', $alert_meta['trigger_action'] ); + $this->assertSame( 'yellow', $alert_meta['color'] ); + + $this->assertSame( Alerts::POST_TYPE, get_post_type( $result['id'] ) ); + + $this->assert_matches_schema( $result, $this->ability->get_output_schema() ); + } + + public function test_respects_disabled_status() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( + array( + 'alert_type' => 'highlight', + 'trigger_author' => 'any', + 'trigger_context' => 'any', + 'trigger_action' => 'any', + 'status' => 'wp_stream_disabled', + ) + ); + + $this->assertSame( 'wp_stream_disabled', $result['status'] ); + } +} diff --git a/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php b/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php new file mode 100644 index 000000000..fa99a602a --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php @@ -0,0 +1,91 @@ +plugin->locations['dir'] . 'abilities/class-ability-create-exclusion-rule.php'; + $this->ability = new Ability_Create_Exclusion_Rule( $this->plugin ); + } + + public function test_name_and_schema_shape() { + $this->assertSame( 'stream/create-exclusion-rule', $this->ability->get_name() ); + + $input = $this->ability->get_input_schema(); + $this->assertSame( 1, $input['minProperties'] ); + $this->assertArrayHasKey( 'author_or_role', $input['properties'] ); + $this->assertArrayHasKey( 'ip_address', $input['properties'] ); + + $output = $this->ability->get_output_schema(); + $this->assertArrayHasKey( 'index', $output['properties'] ); + $this->assertArrayHasKey( 'rule', $output['properties'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_appends_rule_into_parallel_arrays() { + wp_set_current_user( $this->admin_user_id ); + + $option_key = $this->plugin->settings->option_key; + // Clean baseline. + update_option( $option_key, array() ); + + $first = $this->ability->execute( + array( + 'connector' => 'posts', + 'action' => 'updated', + ) + ); + + $this->assertSame( 0, $first['index'] ); + $this->assertSame( 'posts', $first['rule']['connector'] ); + $this->assertSame( 'updated', $first['rule']['action'] ); + $this->assertNull( $first['rule']['author_or_role'] ); + + $second = $this->ability->execute( + array( + 'ip_address' => '127.0.0.1', + ) + ); + + $this->assertSame( 1, $second['index'] ); + + $stored = (array) get_option( $option_key ); + $this->assertSame( array( 0 => '', 1 => '' ), $stored['exclude_rules']['exclude_row'] ); + $this->assertSame( 'posts', $stored['exclude_rules']['connector'][0] ); + $this->assertSame( '', $stored['exclude_rules']['connector'][1] ); + $this->assertSame( '127.0.0.1', $stored['exclude_rules']['ip_address'][1] ); + } + + public function test_schema_rejects_empty_input() { + $result = rest_validate_value_from_schema( array(), $this->ability->get_input_schema() ); + $this->assertWPError( $result ); + } +} diff --git a/tests/phpunit/abilities/test-class-ability-update-settings.php b/tests/phpunit/abilities/test-class-ability-update-settings.php new file mode 100644 index 000000000..31a3c04fd --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-update-settings.php @@ -0,0 +1,90 @@ +plugin->locations['dir'] . 'abilities/class-ability-update-settings.php'; + $this->ability = new Ability_Update_Settings( $this->plugin ); + } + + public function test_name_and_schema_shape() { + $this->assertSame( 'stream/update-settings', $this->ability->get_name() ); + + $input = $this->ability->get_input_schema(); + $this->assertSame( array( 'settings' ), $input['required'] ); + $this->assertSame( 1, $input['properties']['settings']['minProperties'] ); + + $output = $this->ability->get_output_schema(); + $this->assertSame( 'object', $output['type'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_partial_update_preserves_other_keys() { + wp_set_current_user( $this->admin_user_id ); + + $option_key = $this->plugin->settings->option_key; + + // Seed two existing keys. + update_option( + $option_key, + array( + 'general_records_ttl' => 30, + 'advanced_delete_all_records' => 0, + ) + ); + + $result = $this->ability->execute( + array( + 'settings' => array( 'general_records_ttl' => 90 ), + ) + ); + + $this->assertSame( 90, $result['general_records_ttl'] ); + $this->assertSame( 0, $result['advanced_delete_all_records'] ); + + $stored = (array) get_option( $option_key ); + $this->assertSame( 90, $stored['general_records_ttl'] ); + $this->assertSame( 0, $stored['advanced_delete_all_records'] ); + } + + public function test_refreshes_in_memory_options() { + wp_set_current_user( $this->admin_user_id ); + + $this->ability->execute( + array( + 'settings' => array( 'custom_marker' => 'updated' ), + ) + ); + + $this->assertSame( 'updated', $this->plugin->settings->options['custom_marker'] ); + } +} From d13db68223b2d754086995237b0d39109e134fe9 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 15:09:06 +0530 Subject: [PATCH 04/24] Add destructive Stream abilities Add the two destructive abilities required by the ticket: stream/purge-records (filtered DELETE against the Stream records table with a cascading meta delete that mirrors Admin::erase()) and stream/delete-alert (force-delete a wp_stream_alerts post by ID). purge-records refuses to run unless confirm: true is supplied AND at least one filter (older_than_days, connector, context, action) is set, preventing an accidental full table wipe. The row count is computed before the DELETE so the response is meaningful even though the multi-table DELETE returns the combined affected rows. delete-alert returns a 404 WP_Error when the ID is unknown or refers to a non-alert post type, which makes the ability safely idempotent. Both abilities ship with PHPUnit coverage that exercises permissions, schema validation, the happy path, the refusal paths, and (for purge-records) the meta cascade. --- abilities/class-ability-delete-alert.php | 101 +++++++++++ abilities/class-ability-purge-records.php | 165 ++++++++++++++++++ .../test-class-ability-delete-alert.php | 118 +++++++++++++ .../test-class-ability-purge-records.php | 141 +++++++++++++++ 4 files changed, 525 insertions(+) create mode 100644 abilities/class-ability-delete-alert.php create mode 100644 abilities/class-ability-purge-records.php create mode 100644 tests/phpunit/abilities/test-class-ability-delete-alert.php create mode 100644 tests/phpunit/abilities/test-class-ability-purge-records.php diff --git a/abilities/class-ability-delete-alert.php b/abilities/class-ability-delete-alert.php new file mode 100644 index 000000000..81af6fc91 --- /dev/null +++ b/abilities/class-ability-delete-alert.php @@ -0,0 +1,101 @@ + false, + 'destructive' => true, + 'idempotent' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => 'Alert post ID (a wp_stream_alerts post).', + 'minimum' => 1, + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'deleted' => array( 'type' => 'boolean' ), + 'id' => array( 'type' => 'integer' ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + $id = isset( $input['id'] ) ? (int) $input['id'] : 0; + $post = $id > 0 ? get_post( $id ) : null; + + if ( ! $post || Alerts::POST_TYPE !== $post->post_type ) { + return new \WP_Error( + 'stream_alert_not_found', + __( 'Alert not found.', 'stream' ), + array( 'status' => 404 ) + ); + } + + $result = wp_delete_post( $id, true ); + + return array( + 'deleted' => (bool) $result, + 'id' => $id, + ); + } +} diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php new file mode 100644 index 000000000..ed5ce8c63 --- /dev/null +++ b/abilities/class-ability-purge-records.php @@ -0,0 +1,165 @@ + false, + 'destructive' => true, + ); + } + + /** + * {@inheritDoc} + */ + public function get_input_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'confirm' ), + 'properties' => array( + 'confirm' => array( + 'type' => 'boolean', + 'description' => 'Must be true to authorize the destructive purge.', + 'enum' => array( true ), + ), + 'older_than_days' => array( + 'type' => 'integer', + 'description' => 'Delete only records created more than this many days ago.', + 'minimum' => 1, + ), + 'connector' => array( + 'type' => 'string', + 'description' => 'Connector slug to limit the purge to.', + ), + 'context' => array( + 'type' => 'string', + 'description' => 'Context slug to limit the purge to.', + ), + 'action' => array( + 'type' => 'string', + 'description' => 'Action slug to limit the purge to.', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'deleted' => array( + 'type' => 'integer', + 'description' => 'Number of stream records deleted (meta rows are cascaded by record_id).', + 'minimum' => 0, + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + global $wpdb; + + if ( empty( $input['confirm'] ) ) { + return new \WP_Error( + 'stream_purge_not_confirmed', + __( 'Purge requires confirm: true.', 'stream' ), + array( 'status' => 400 ) + ); + } + + $where = array( '1=1' ); + $params = array(); + + if ( ! empty( $input['older_than_days'] ) ) { + $where[] = "stream.created < DATE_SUB(NOW(), INTERVAL %d DAY)"; + $params[] = (int) $input['older_than_days']; + } + if ( ! empty( $input['connector'] ) ) { + $where[] = 'stream.connector = %s'; + $params[] = (string) $input['connector']; + } + if ( ! empty( $input['context'] ) ) { + $where[] = 'stream.context = %s'; + $params[] = (string) $input['context']; + } + if ( ! empty( $input['action'] ) ) { + $where[] = 'stream.action = %s'; + $params[] = (string) $input['action']; + } + + // Reject confirm-only payloads (no actual filter): refuse rather than truncate the table. + if ( count( $where ) === 1 ) { + return new \WP_Error( + 'stream_purge_no_filter', + __( 'At least one filter (older_than_days, connector, context, action) must be supplied.', 'stream' ), + array( 'status' => 400 ) + ); + } + + $where_sql = implode( ' AND ', $where ); + + // Count first so the response is meaningful even if the cascade DELETE returns the + // combined affected rows from both tables. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery + $count_sql = "SELECT COUNT(*) FROM {$wpdb->stream} AS stream WHERE {$where_sql}"; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery + $prepared_count = empty( $params ) ? $count_sql : $wpdb->prepare( $count_sql, $params ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $deleted = (int) $wpdb->get_var( $prepared_count ); + + if ( 0 === $deleted ) { + return array( 'deleted' => 0 ); + } + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery + $delete_sql = "DELETE stream, meta FROM {$wpdb->stream} AS stream LEFT JOIN {$wpdb->streammeta} AS meta ON meta.record_id = stream.ID WHERE {$where_sql}"; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery + $prepared_delete = empty( $params ) ? $delete_sql : $wpdb->prepare( $delete_sql, $params ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->query( $prepared_delete ); + + return array( 'deleted' => $deleted ); + } +} diff --git a/tests/phpunit/abilities/test-class-ability-delete-alert.php b/tests/phpunit/abilities/test-class-ability-delete-alert.php new file mode 100644 index 000000000..32dc82f4c --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-delete-alert.php @@ -0,0 +1,118 @@ +plugin->locations['dir'] . 'abilities/class-ability-delete-alert.php'; + $this->ability = new Ability_Delete_Alert( $this->plugin ); + } + + public function test_name_and_schema_shape() { + $this->assertSame( 'stream/delete-alert', $this->ability->get_name() ); + + $input = $this->ability->get_input_schema(); + $this->assertSame( array( 'id' ), $input['required'] ); + $this->assertSame( 1, $input['properties']['id']['minimum'] ); + + $annotations = $this->ability->get_annotations(); + $this->assertTrue( $annotations['destructive'] ); + $this->assertTrue( $annotations['idempotent'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_deletes_existing_alert() { + wp_set_current_user( $this->admin_user_id ); + + $alert_id = wp_insert_post( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => 'wp_stream_enabled', + 'post_title' => 'To be deleted', + ) + ); + + $result = $this->ability->execute( array( 'id' => $alert_id ) ); + + $this->assertSame( array( 'deleted' => true, 'id' => $alert_id ), $result ); + $this->assertNull( get_post( $alert_id ) ); + + $this->assert_matches_schema( $result, $this->ability->get_output_schema() ); + } + + public function test_returns_404_for_unknown_id() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( array( 'id' => 999999 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'stream_alert_not_found', $result->get_error_code() ); + $this->assertSame( 404, $result->get_error_data()['status'] ); + } + + public function test_refuses_non_alert_post() { + wp_set_current_user( $this->admin_user_id ); + + $post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Regular post', + ) + ); + + $result = $this->ability->execute( array( 'id' => $post_id ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'stream_alert_not_found', $result->get_error_code() ); + + // Original post must still exist. + $this->assertNotNull( get_post( $post_id ) ); + } + + public function test_idempotent_second_call_returns_404() { + wp_set_current_user( $this->admin_user_id ); + + $alert_id = wp_insert_post( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => 'wp_stream_enabled', + ) + ); + + $first = $this->ability->execute( array( 'id' => $alert_id ) ); + $second = $this->ability->execute( array( 'id' => $alert_id ) ); + + $this->assertTrue( $first['deleted'] ); + $this->assertWPError( $second ); + $this->assertSame( 'stream_alert_not_found', $second->get_error_code() ); + } +} diff --git a/tests/phpunit/abilities/test-class-ability-purge-records.php b/tests/phpunit/abilities/test-class-ability-purge-records.php new file mode 100644 index 000000000..2f4086bd9 --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-purge-records.php @@ -0,0 +1,141 @@ +plugin->locations['dir'] . 'abilities/class-ability-purge-records.php'; + $this->ability = new Ability_Purge_Records( $this->plugin ); + } + + public function test_name_and_schema_shape() { + $this->assertSame( 'stream/purge-records', $this->ability->get_name() ); + + $input = $this->ability->get_input_schema(); + $this->assertSame( array( 'confirm' ), $input['required'] ); + $this->assertSame( array( true ), $input['properties']['confirm']['enum'] ); + + $annotations = $this->ability->get_annotations(); + $this->assertTrue( $annotations['destructive'] ); + } + + public function test_permissions() { + wp_set_current_user( $this->subscriber_user_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $this->admin_user_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_refuses_when_no_filter_supplied() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( array( 'confirm' => true ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'stream_purge_no_filter', $result->get_error_code() ); + } + + public function test_refuses_when_not_confirmed() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( array( 'connector' => 'users' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'stream_purge_not_confirmed', $result->get_error_code() ); + } + + public function test_deletes_only_matching_rows_and_cascades_meta() { + global $wpdb; + + wp_set_current_user( $this->admin_user_id ); + + // Seed: one users record (target) and one posts record (preserved). + $this->plugin->log->log( + 'users', + 'Target record', + array( 'meta_one' => 'value' ), + 0, + 'users', + 'created' + ); + $this->plugin->log->log( + 'posts', + 'Preserved record', + array( 'meta_two' => 'value' ), + 0, + 'posts', + 'updated' + ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $total_before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); + $this->assertGreaterThanOrEqual( 2, $total_before ); + + $result = $this->ability->execute( + array( + 'confirm' => true, + 'connector' => 'users', + ) + ); + + $this->assertIsArray( $result ); + $this->assertGreaterThanOrEqual( 1, $result['deleted'] ); + + // Users records gone. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $users_left = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream} WHERE connector = 'users'" ); + $this->assertSame( 0, $users_left ); + + // Posts records untouched. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $posts_left = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream} WHERE connector = 'posts'" ); + $this->assertGreaterThanOrEqual( 1, $posts_left ); + + // No orphaned meta for users connector — the cascade DELETE should have + // removed any meta rows whose record_id was deleted. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $orphans = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->streammeta} meta + LEFT JOIN {$wpdb->stream} stream ON stream.ID = meta.record_id + WHERE stream.ID IS NULL" + ); + $this->assertSame( 0, $orphans ); + + $this->assert_matches_schema( $result, $this->ability->get_output_schema() ); + } + + public function test_zero_match_returns_zero_count() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( + array( + 'confirm' => true, + 'connector' => 'definitely-not-a-real-connector', + ) + ); + + $this->assertSame( array( 'deleted' => 0 ), $result ); + } +} From 4ad87227200cf9df6fd7fe4b7c2090c24321634e Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 15:11:40 +0530 Subject: [PATCH 05/24] Add Abilities loader and base class tests Cover the two infrastructure pieces left untested by the per-ability suites: tests/phpunit/test-class-ability.php exercises the Ability abstract base via an in-file Fake_Ability_For_Test subclass (verifies get_meta() emits category and show_in_rest, conditionally adds an annotations key, and that the default permission_callback denies subscribers and grants admins; also asserts register() makes the ability retrievable via wp_get_ability() when the API is available). tests/phpunit/test-class-abilities.php covers the loader: is_available() tracks the WP_Ability class presence, is_enabled() reflects the advanced_enable_abilities_api option, the constructor only hooks wp_abilities_api_init when both gates pass, get_ability_slugs() lists all eleven slugs, load_abilities() instantiates each, and register_abilities() does not double-load on a second invocation. Resolves: XWPENG-13 --- tests/phpunit/test-class-abilities.php | 121 +++++++++++++++++++ tests/phpunit/test-class-ability.php | 161 +++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 tests/phpunit/test-class-abilities.php create mode 100644 tests/phpunit/test-class-ability.php diff --git a/tests/phpunit/test-class-abilities.php b/tests/phpunit/test-class-abilities.php new file mode 100644 index 000000000..20e5a403d --- /dev/null +++ b/tests/phpunit/test-class-abilities.php @@ -0,0 +1,121 @@ +original_options = isset( $this->plugin->settings->options ) + ? (array) $this->plugin->settings->options + : array(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void { + $this->plugin->settings->options = $this->original_options; + parent::tearDown(); + } + + public function test_is_available_matches_wp_ability_class_presence() { + $abilities = new Abilities( $this->plugin ); + $this->assertSame( class_exists( '\WP_Ability' ), $abilities->is_available() ); + } + + public function test_is_enabled_reflects_settings_option() { + $abilities = new Abilities( $this->plugin ); + + $this->plugin->settings->options['advanced_enable_abilities_api'] = 0; + $this->assertFalse( $abilities->is_enabled() ); + + $this->plugin->settings->options['advanced_enable_abilities_api'] = 1; + $this->assertTrue( $abilities->is_enabled() ); + } + + public function test_constructor_does_not_hook_when_setting_disabled() { + $this->plugin->settings->options['advanced_enable_abilities_api'] = 0; + + // Clear any leftover hooks from prior tests. + remove_all_actions( 'wp_abilities_api_init' ); + + new Abilities( $this->plugin ); + + $this->assertFalse( has_action( 'wp_abilities_api_init' ) ); + } + + public function test_constructor_hooks_when_available_and_enabled() { + if ( ! class_exists( '\WP_Ability' ) ) { + $this->markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); + } + + $this->plugin->settings->options['advanced_enable_abilities_api'] = 1; + + remove_all_actions( 'wp_abilities_api_init' ); + + $abilities = new Abilities( $this->plugin ); + + $this->assertNotFalse( has_action( 'wp_abilities_api_init', array( $abilities, 'register_abilities' ) ) ); + } + + public function test_get_ability_slugs_lists_all_eleven() { + $abilities = new Abilities( $this->plugin ); + $slugs = $abilities->get_ability_slugs(); + + $this->assertCount( 11, $slugs ); + $this->assertContains( 'get-records', $slugs ); + $this->assertContains( 'get-record', $slugs ); + $this->assertContains( 'get-settings', $slugs ); + $this->assertContains( 'get-alerts', $slugs ); + $this->assertContains( 'get-connectors', $slugs ); + $this->assertContains( 'get-exclusion-rules', $slugs ); + $this->assertContains( 'create-alert', $slugs ); + $this->assertContains( 'update-settings', $slugs ); + $this->assertContains( 'create-exclusion-rule', $slugs ); + $this->assertContains( 'purge-records', $slugs ); + $this->assertContains( 'delete-alert', $slugs ); + } + + public function test_load_abilities_instantiates_each_slug() { + $abilities = new Abilities( $this->plugin ); + $abilities->load_abilities(); + + $this->assertCount( 11, $abilities->abilities ); + + foreach ( $abilities->abilities as $name => $ability ) { + $this->assertInstanceOf( __NAMESPACE__ . '\\Ability', $ability ); + $this->assertSame( $name, $ability->get_name() ); + } + } + + public function test_register_abilities_is_idempotent_about_loading() { + $abilities = new Abilities( $this->plugin ); + + // First call loads and registers. + $abilities->register_abilities(); + $count_after_first = count( $abilities->abilities ); + + // Second call must not re-load (the load_abilities() guard checks the property). + $abilities->register_abilities(); + $this->assertSame( $count_after_first, count( $abilities->abilities ) ); + } +} diff --git a/tests/phpunit/test-class-ability.php b/tests/phpunit/test-class-ability.php new file mode 100644 index 000000000..f3108eafd --- /dev/null +++ b/tests/phpunit/test-class-ability.php @@ -0,0 +1,161 @@ + 'object', + 'properties' => array( + 'foo' => array( 'type' => 'string' ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( 'type' => 'string' ); + } + + /** + * {@inheritDoc} + */ + public function get_annotations() { + return $this->annotations; + } + + /** + * {@inheritDoc} + */ + public function execute( $input ) { + $this->last_input = $input; + return 'ok'; + } +} + +/** + * Class - Test_Ability + */ +class Test_Ability extends WP_StreamTestCase { + + /** + * Ability under test. + * + * @var Fake_Ability_For_Test + */ + protected $ability; + + /** + * {@inheritDoc} + */ + public function setUp(): void { + parent::setUp(); + $this->ability = new Fake_Ability_For_Test( $this->plugin ); + } + + public function test_get_meta_includes_category_and_rest_exposure() { + $meta = $this->ability->get_meta(); + $this->assertSame( Abilities::CATEGORY_SLUG, $meta['category'] ); + $this->assertTrue( $meta['show_in_rest'] ); + $this->assertArrayNotHasKey( 'annotations', $meta ); + } + + public function test_get_meta_includes_annotations_when_set() { + $this->ability->annotations = array( + 'readonly' => true, + 'idempotent' => true, + ); + + $meta = $this->ability->get_meta(); + $this->assertSame( + array( + 'readonly' => true, + 'idempotent' => true, + ), + $meta['annotations'] + ); + } + + public function test_default_permission_callback_admin_vs_subscriber() { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + wp_set_current_user( $subscriber_id ); + $this->assertFalse( $this->ability->permission_callback() ); + + wp_set_current_user( $admin_id ); + $this->assertTrue( $this->ability->permission_callback() ); + } + + public function test_register_is_noop_without_wp_register_ability() { + if ( function_exists( 'wp_register_ability' ) ) { + $this->markTestSkipped( 'Cannot exercise the no-op branch when wp_register_ability() exists.' ); + } + + // Should silently return without raising. + $this->ability->register(); + $this->assertTrue( true ); + } + + public function test_register_makes_ability_retrievable() { + if ( ! function_exists( 'wp_register_ability' ) || ! function_exists( 'wp_get_ability' ) ) { + $this->markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); + } + + $this->ability->register(); + + $registered = wp_get_ability( 'stream/fake' ); + $this->assertNotNull( $registered ); + } +} From 938a6ae4fbd44b5416b2573787ec3b9cb9ba1fba Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 15:29:50 +0530 Subject: [PATCH 06/24] Fix lint errors in abilities tests and purge ability --- abilities/class-ability-purge-records.php | 14 ++- ...st-class-ability-create-exclusion-rule.php | 8 +- .../test-class-ability-delete-alert.php | 8 +- tests/phpunit/fake-ability.php | 89 +++++++++++++++++++ tests/phpunit/test-class-ability.php | 77 +--------------- 5 files changed, 110 insertions(+), 86 deletions(-) create mode 100644 tests/phpunit/fake-ability.php diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index ed5ce8c63..f2fbc998a 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -113,7 +113,7 @@ public function execute( $input ) { $params = array(); if ( ! empty( $input['older_than_days'] ) ) { - $where[] = "stream.created < DATE_SUB(NOW(), INTERVAL %d DAY)"; + $where[] = 'stream.created < DATE_SUB(NOW(), INTERVAL %d DAY)'; $params[] = (int) $input['older_than_days']; } if ( ! empty( $input['connector'] ) ) { @@ -141,13 +141,13 @@ public function execute( $input ) { $where_sql = implode( ' AND ', $where ); // Count first so the response is meaningful even if the cascade DELETE returns the - // combined affected rows from both tables. + // combined affected rows from both tables. By the time we reach this point at least + // one filter has been added (the count( $where ) === 1 guard above ensures $params + // is non-empty), so $wpdb->prepare() is always called with placeholders. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery $count_sql = "SELECT COUNT(*) FROM {$wpdb->stream} AS stream WHERE {$where_sql}"; - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery - $prepared_count = empty( $params ) ? $count_sql : $wpdb->prepare( $count_sql, $params ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $deleted = (int) $wpdb->get_var( $prepared_count ); + $deleted = (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared if ( 0 === $deleted ) { return array( 'deleted' => 0 ); @@ -155,10 +155,8 @@ public function execute( $input ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery $delete_sql = "DELETE stream, meta FROM {$wpdb->stream} AS stream LEFT JOIN {$wpdb->streammeta} AS meta ON meta.record_id = stream.ID WHERE {$where_sql}"; - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery - $prepared_delete = empty( $params ) ? $delete_sql : $wpdb->prepare( $delete_sql, $params ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $wpdb->query( $prepared_delete ); + $wpdb->query( $wpdb->prepare( $delete_sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared return array( 'deleted' => $deleted ); } diff --git a/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php b/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php index fa99a602a..a1f2e2c8a 100644 --- a/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php +++ b/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php @@ -78,7 +78,13 @@ public function test_appends_rule_into_parallel_arrays() { $this->assertSame( 1, $second['index'] ); $stored = (array) get_option( $option_key ); - $this->assertSame( array( 0 => '', 1 => '' ), $stored['exclude_rules']['exclude_row'] ); + $this->assertSame( + array( + 0 => '', + 1 => '', + ), + $stored['exclude_rules']['exclude_row'] + ); $this->assertSame( 'posts', $stored['exclude_rules']['connector'][0] ); $this->assertSame( '', $stored['exclude_rules']['connector'][1] ); $this->assertSame( '127.0.0.1', $stored['exclude_rules']['ip_address'][1] ); diff --git a/tests/phpunit/abilities/test-class-ability-delete-alert.php b/tests/phpunit/abilities/test-class-ability-delete-alert.php index 32dc82f4c..4f60b46c8 100644 --- a/tests/phpunit/abilities/test-class-ability-delete-alert.php +++ b/tests/phpunit/abilities/test-class-ability-delete-alert.php @@ -62,7 +62,13 @@ public function test_deletes_existing_alert() { $result = $this->ability->execute( array( 'id' => $alert_id ) ); - $this->assertSame( array( 'deleted' => true, 'id' => $alert_id ), $result ); + $this->assertSame( + array( + 'deleted' => true, + 'id' => $alert_id, + ), + $result + ); $this->assertNull( get_post( $alert_id ) ); $this->assert_matches_schema( $result, $this->ability->get_output_schema() ); diff --git a/tests/phpunit/fake-ability.php b/tests/phpunit/fake-ability.php new file mode 100644 index 000000000..7e320bc23 --- /dev/null +++ b/tests/phpunit/fake-ability.php @@ -0,0 +1,89 @@ + 'object', + 'properties' => array( + 'foo' => array( 'type' => 'string' ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( 'type' => 'string' ); + } + + /** + * {@inheritDoc} + */ + public function get_annotations() { + return $this->annotations; + } + + /** + * Capture input and return a deterministic result. + * + * @param array $input Input matching get_input_schema(). + * @return string + */ + public function execute( $input ) { + $this->last_input = $input; + return 'ok'; + } +} diff --git a/tests/phpunit/test-class-ability.php b/tests/phpunit/test-class-ability.php index f3108eafd..0ba8763e6 100644 --- a/tests/phpunit/test-class-ability.php +++ b/tests/phpunit/test-class-ability.php @@ -7,82 +7,7 @@ namespace WP_Stream; -/** - * Concrete fake used to exercise the abstract base. - * - * Defined at file scope (not inside a method) so PHP can autoload it. - */ -class Fake_Ability_For_Test extends Ability { - - /** - * Annotations for the fake ability. Toggled by tests. - * - * @var array - */ - public $annotations = array(); - - /** - * Last input received by execute(), for test inspection. - * - * @var array|null - */ - public $last_input; - - /** - * {@inheritDoc} - */ - public function get_name() { - return 'stream/fake'; - } - - /** - * {@inheritDoc} - */ - public function get_label() { - return 'Fake'; - } - - /** - * {@inheritDoc} - */ - public function get_description() { - return 'Fake ability used in unit tests.'; - } - - /** - * {@inheritDoc} - */ - public function get_input_schema() { - return array( - 'type' => 'object', - 'properties' => array( - 'foo' => array( 'type' => 'string' ), - ), - ); - } - - /** - * {@inheritDoc} - */ - public function get_output_schema() { - return array( 'type' => 'string' ); - } - - /** - * {@inheritDoc} - */ - public function get_annotations() { - return $this->annotations; - } - - /** - * {@inheritDoc} - */ - public function execute( $input ) { - $this->last_input = $input; - return 'ok'; - } -} +require_once __DIR__ . '/fake-ability.php'; /** * Class - Test_Ability From 1da702d66d256ed9f86214349ed9bccfeaa3b0c9 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 15:42:51 +0530 Subject: [PATCH 07/24] Register stream ability category and fix get-record lookup - Register 'stream' category on wp_abilities_api_categories_init so abilities with category=stream pass core's category-existence check - Move 'category' from meta to top-level args in Ability::register(), matching the wp_register_ability() contract in WP 6.9 - Replace get-record's broken DB::get_records(['record' => $id]) call (Query class never implemented the singular 'record' arg) with a direct $wpdb single-row lookup - Snapshot/restore $plugin->settings->options in Abilities_TestCase so in-memory mutations from write-ability tests don't leak across tests - Update tests to satisfy the doing_action() guards on wp_register_ability() and wp_register_ability_category() --- abilities/class-ability-get-record.php | 16 +++++++--- classes/class-abilities.php | 20 ++++++++++++ classes/class-ability.php | 4 +-- .../phpunit/abilities/abilities-testcase.php | 25 +++++++++++++++ .../test-class-ability-get-records.php | 1 - tests/phpunit/test-class-abilities.php | 32 +++++++++++++++---- tests/phpunit/test-class-ability.php | 32 +++++++++++++++++-- 7 files changed, 112 insertions(+), 18 deletions(-) diff --git a/abilities/class-ability-get-record.php b/abilities/class-ability-get-record.php index a063a329d..c53341ff4 100644 --- a/abilities/class-ability-get-record.php +++ b/abilities/class-ability-get-record.php @@ -93,11 +93,18 @@ public function get_output_schema() { * {@inheritDoc} */ public function execute( $input ) { + global $wpdb; + $id = isset( $input['id'] ) ? (int) $input['id'] : 0; - $records = $this->plugin->db->get_records( array( 'record' => $id ) ); + // Stream's Query class doesn't expose a single-ID filter, so query the table directly. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$wpdb->stream} WHERE ID = %d", $id ), + ARRAY_A + ); - if ( empty( $records ) ) { + if ( empty( $row ) ) { return new \WP_Error( 'stream_record_not_found', __( 'Record not found.', 'stream' ), @@ -105,9 +112,8 @@ public function execute( $input ) { ); } - $record = (array) $records[0]; - $record['meta'] = (array) get_metadata( 'record', $record['ID'] ); + $row['meta'] = (array) get_metadata( 'record', $row['ID'] ); - return $record; + return $row; } } diff --git a/classes/class-abilities.php b/classes/class-abilities.php index 222d90f43..eeae4d4e7 100644 --- a/classes/class-abilities.php +++ b/classes/class-abilities.php @@ -63,9 +63,29 @@ public function __construct( Plugin $plugin ) { return; } + add_action( 'wp_abilities_api_categories_init', array( $this, 'register_category' ) ); add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); } + /** + * Hooked to wp_abilities_api_categories_init. Registers the "stream" ability category. + * + * @return void + */ + public function register_category() { + if ( ! function_exists( 'wp_register_ability_category' ) ) { + return; + } + + wp_register_ability_category( + self::CATEGORY_SLUG, + array( + 'label' => __( 'Stream', 'stream' ), + 'description' => __( 'Abilities that read or modify Stream activity logs and configuration.', 'stream' ), + ) + ); + } + /** * Whether the WordPress Abilities API is available (WP 6.9+). * diff --git a/classes/class-ability.php b/classes/class-ability.php index 548b60ff2..b72e8ffd7 100644 --- a/classes/class-ability.php +++ b/classes/class-ability.php @@ -97,13 +97,12 @@ public function get_annotations() { } /** - * Meta passed to wp_register_ability(). Sets category and REST exposure. + * Meta passed to wp_register_ability(). Sets REST exposure and (optionally) annotations. * * @return array */ public function get_meta() { $meta = array( - 'category' => Abilities::CATEGORY_SLUG, 'show_in_rest' => true, ); @@ -128,6 +127,7 @@ final public function register() { $args = array( 'label' => $this->get_label(), 'description' => $this->get_description(), + 'category' => Abilities::CATEGORY_SLUG, 'output_schema' => $this->get_output_schema(), 'execute_callback' => array( $this, 'execute' ), 'permission_callback' => array( $this, 'permission_callback' ), diff --git a/tests/phpunit/abilities/abilities-testcase.php b/tests/phpunit/abilities/abilities-testcase.php index db1cc9081..cb03b3887 100644 --- a/tests/phpunit/abilities/abilities-testcase.php +++ b/tests/phpunit/abilities/abilities-testcase.php @@ -31,6 +31,13 @@ abstract class Abilities_TestCase extends WP_StreamTestCase { */ protected $subscriber_user_id; + /** + * Snapshot of $plugin->settings->options at setUp() time, restored in tearDown(). + * + * @var array + */ + private $options_snapshot = array(); + /** * {@inheritDoc} */ @@ -43,6 +50,24 @@ public function setUp(): void { $this->admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); $this->subscriber_user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + // Snapshot the in-memory options so write abilities can mutate them in a test + // without leaking state into the next test (the underlying wp_options DB row is + // rolled back by the WP test framework, but the singleton $plugin->settings->options + // array survives between tests in the same process). + $this->options_snapshot = isset( $this->plugin->settings->options ) + ? (array) $this->plugin->settings->options + : array(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void { + if ( isset( $this->plugin->settings ) ) { + $this->plugin->settings->options = $this->options_snapshot; + } + parent::tearDown(); } /** diff --git a/tests/phpunit/abilities/test-class-ability-get-records.php b/tests/phpunit/abilities/test-class-ability-get-records.php index 55acdc9a6..c7a801248 100644 --- a/tests/phpunit/abilities/test-class-ability-get-records.php +++ b/tests/phpunit/abilities/test-class-ability-get-records.php @@ -50,7 +50,6 @@ public function test_output_schema_is_object_with_records_and_total() { public function test_meta_marks_readonly_idempotent_and_rest_exposed() { $meta = $this->ability->get_meta(); $this->assertTrue( $meta['show_in_rest'] ); - $this->assertSame( 'stream', $meta['category'] ); $this->assertTrue( $meta['annotations']['readonly'] ); $this->assertTrue( $meta['annotations']['idempotent'] ); } diff --git a/tests/phpunit/test-class-abilities.php b/tests/phpunit/test-class-abilities.php index 20e5a403d..ad9d4c796 100644 --- a/tests/phpunit/test-class-abilities.php +++ b/tests/phpunit/test-class-abilities.php @@ -107,15 +107,33 @@ public function test_load_abilities_instantiates_each_slug() { } } - public function test_register_abilities_is_idempotent_about_loading() { + public function test_load_abilities_populates_all_slugs() { $abilities = new Abilities( $this->plugin ); - // First call loads and registers. - $abilities->register_abilities(); - $count_after_first = count( $abilities->abilities ); + $abilities->load_abilities(); + + $this->assertCount( 11, $abilities->abilities ); + foreach ( $abilities->get_ability_slugs() as $slug ) { + $this->assertArrayHasKey( 'stream/' . $slug, $abilities->abilities ); + } + } + + public function test_register_abilities_loads_and_registers_when_action_fires() { + if ( ! class_exists( '\WP_Ability' ) ) { + $this->markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); + } + + // Enable the setting so the constructor wires both category + abilities hooks. + $this->plugin->settings->options['advanced_enable_abilities_api'] = 1; + remove_all_actions( 'wp_abilities_api_categories_init' ); + remove_all_actions( 'wp_abilities_api_init' ); - // Second call must not re-load (the load_abilities() guard checks the property). - $abilities->register_abilities(); - $this->assertSame( $count_after_first, count( $abilities->abilities ) ); + $abilities = new Abilities( $this->plugin ); + + // wp_get_ability() lazily fires both init actions. + $retrieved = wp_get_ability( 'stream/get-records' ); + + $this->assertNotNull( $retrieved ); + $this->assertCount( 11, $abilities->abilities ); } } diff --git a/tests/phpunit/test-class-ability.php b/tests/phpunit/test-class-ability.php index 0ba8763e6..02a1bd4e8 100644 --- a/tests/phpunit/test-class-ability.php +++ b/tests/phpunit/test-class-ability.php @@ -29,9 +29,8 @@ public function setUp(): void { $this->ability = new Fake_Ability_For_Test( $this->plugin ); } - public function test_get_meta_includes_category_and_rest_exposure() { + public function test_get_meta_exposes_in_rest_without_annotations_by_default() { $meta = $this->ability->get_meta(); - $this->assertSame( Abilities::CATEGORY_SLUG, $meta['category'] ); $this->assertTrue( $meta['show_in_rest'] ); $this->assertArrayNotHasKey( 'annotations', $meta ); } @@ -78,7 +77,34 @@ public function test_register_makes_ability_retrievable() { $this->markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); } - $this->ability->register(); + // Both wp_register_ability_category() and wp_register_ability() are gated on + // doing_action(...); fire each action manually with our callbacks hooked. This + // also works after another test in the suite has already booted the registry, + // because the doing_action() check just looks at the current action stack. + add_action( + 'wp_abilities_api_categories_init', + static function () { + if ( null === wp_get_ability_category( Abilities::CATEGORY_SLUG ) ) { + wp_register_ability_category( + Abilities::CATEGORY_SLUG, + array( + 'label' => 'Stream', + 'description' => 'Stream test category.', + ) + ); + } + } + ); + do_action( 'wp_abilities_api_categories_init' ); + + $ability = $this->ability; + add_action( + 'wp_abilities_api_init', + static function () use ( $ability ) { + $ability->register(); + } + ); + do_action( 'wp_abilities_api_init' ); $registered = wp_get_ability( 'stream/fake' ); $this->assertNotNull( $registered ); From 561073fd45fd4842f7b0428bc424454d31fdb082 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 16:42:44 +0530 Subject: [PATCH 08/24] Add ability instructions, REST integration tests, fix HTTP routing Add a per-ability 'instructions' annotation: a 1-2 sentence note for AI agents about when and how to call each ability, distinct from the description (which describes what it does). Add tests/phpunit/abilities/test-rest-integration.php covering all three ability types end-to-end: dispatches actual WP_REST_Requests through WP_REST_Server and asserts 200/403/404/405 paths plus the list-abilities endpoint exposes all 11 stream/* abilities. Catches breakage in the real REST stack that direct execute() tests miss. Add idempotent: true to purge-records annotations. WP core's REST router only routes to DELETE when destructive AND idempotent are both true; without idempotent the controller expects POST. Refactor test action-firing to use the documented core test pattern of pushing onto $wp_current_filter rather than registering callbacks through add_action(). Cleaner, no global hook pollution, matches the convention used in WordPress core's own abilities-api tests. Make Abilities::register_abilities() defensive: skip per-ability register() calls when the ability is already registered, preventing spurious _doing_it_wrong notices when load_abilities() runs more than once in the same process. --- abilities/class-ability-create-alert.php | 3 +- .../class-ability-create-exclusion-rule.php | 3 +- abilities/class-ability-delete-alert.php | 7 +- abilities/class-ability-get-alerts.php | 5 +- abilities/class-ability-get-connectors.php | 5 +- .../class-ability-get-exclusion-rules.php | 5 +- abilities/class-ability-get-record.php | 5 +- abilities/class-ability-get-records.php | 5 +- abilities/class-ability-get-settings.php | 5 +- abilities/class-ability-purge-records.php | 8 +- abilities/class-ability-update-settings.php | 5 +- classes/class-abilities.php | 6 + .../phpunit/abilities/abilities-testcase.php | 78 +++++ .../abilities/test-rest-integration.php | 293 ++++++++++++++++++ tests/phpunit/test-class-abilities.php | 24 +- tests/phpunit/test-class-ability.php | 67 ++-- 16 files changed, 467 insertions(+), 57 deletions(-) create mode 100644 tests/phpunit/abilities/test-rest-integration.php diff --git a/abilities/class-ability-create-alert.php b/abilities/class-ability-create-alert.php index 43a1303ad..8792c6d73 100644 --- a/abilities/class-ability-create-alert.php +++ b/abilities/class-ability-create-alert.php @@ -38,7 +38,8 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => false, + 'readonly' => false, + 'instructions' => __( 'Create an alert that fires whenever a record matches the supplied filters. Validate the connector/context/action with stream/get-connectors first, and confirm with the user before creating the alert because it changes site behavior.', 'stream' ), ); } diff --git a/abilities/class-ability-create-exclusion-rule.php b/abilities/class-ability-create-exclusion-rule.php index 742d02a94..649355994 100644 --- a/abilities/class-ability-create-exclusion-rule.php +++ b/abilities/class-ability-create-exclusion-rule.php @@ -51,7 +51,8 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => false, + 'readonly' => false, + 'instructions' => __( 'Add a rule that prevents matching activity from ever being logged. Confirm intent with the user before calling: excluded events cannot be recovered later. Validate filter values with stream/get-connectors when in doubt.', 'stream' ), ); } diff --git a/abilities/class-ability-delete-alert.php b/abilities/class-ability-delete-alert.php index 81af6fc91..3776c8ac8 100644 --- a/abilities/class-ability-delete-alert.php +++ b/abilities/class-ability-delete-alert.php @@ -38,9 +38,10 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => false, - 'destructive' => true, - 'idempotent' => true, + 'readonly' => false, + 'destructive' => true, + 'idempotent' => true, + 'instructions' => __( 'Permanently deletes an alert by ID. Run stream/get-alerts first to confirm the alert exists and to show the user which rule will be removed. Idempotent: deleting an already-deleted alert returns 404, not an error to retry.', 'stream' ), ); } diff --git a/abilities/class-ability-get-alerts.php b/abilities/class-ability-get-alerts.php index 1e601a83d..1c0c85974 100644 --- a/abilities/class-ability-get-alerts.php +++ b/abilities/class-ability-get-alerts.php @@ -38,8 +38,9 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => true, - 'idempotent' => true, + 'readonly' => true, + 'idempotent' => true, + 'instructions' => __( 'Use to enumerate existing alert rules before creating new ones (stream/create-alert) or removing them (stream/delete-alert). Pass status="enabled" to see only active alerts.', 'stream' ), ); } diff --git a/abilities/class-ability-get-connectors.php b/abilities/class-ability-get-connectors.php index 574370f38..52b51b2b3 100644 --- a/abilities/class-ability-get-connectors.php +++ b/abilities/class-ability-get-connectors.php @@ -38,8 +38,9 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => true, - 'idempotent' => true, + 'readonly' => true, + 'idempotent' => true, + 'instructions' => __( 'Use to discover the valid connector / context / action values for filters in stream/get-records and stream/create-exclusion-rule. Connector slugs are stable identifiers, so cache the result if you call abilities repeatedly.', 'stream' ), ); } diff --git a/abilities/class-ability-get-exclusion-rules.php b/abilities/class-ability-get-exclusion-rules.php index a864d9d2e..e334e0456 100644 --- a/abilities/class-ability-get-exclusion-rules.php +++ b/abilities/class-ability-get-exclusion-rules.php @@ -38,8 +38,9 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => true, - 'idempotent' => true, + 'readonly' => true, + 'idempotent' => true, + 'instructions' => __( 'Use to inspect which activity is being silently dropped before it reaches the log. Run before stream/create-exclusion-rule so you can avoid duplicate rules.', 'stream' ), ); } diff --git a/abilities/class-ability-get-record.php b/abilities/class-ability-get-record.php index c53341ff4..35307de09 100644 --- a/abilities/class-ability-get-record.php +++ b/abilities/class-ability-get-record.php @@ -38,8 +38,9 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => true, - 'idempotent' => true, + 'readonly' => true, + 'idempotent' => true, + 'instructions' => __( 'Use after stream/get-records to fetch the full record plus its metadata for a specific log entry, when summary fields from the list response are not enough. Pass the integer ID returned by stream/get-records.', 'stream' ), ); } diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php index e0140e42c..f9a891899 100644 --- a/abilities/class-ability-get-records.php +++ b/abilities/class-ability-get-records.php @@ -52,8 +52,9 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => true, - 'idempotent' => true, + 'readonly' => true, + 'idempotent' => true, + 'instructions' => __( 'Use to investigate site activity. Always pass narrow filters (date range, user, connector) where possible: the activity log can be very large, and unfiltered queries are paginated to records_per_page (default 20). Combine with stream/get-record when you need full metadata for a specific event.', 'stream' ), ); } diff --git a/abilities/class-ability-get-settings.php b/abilities/class-ability-get-settings.php index ccc14fe04..1ebcd86c8 100644 --- a/abilities/class-ability-get-settings.php +++ b/abilities/class-ability-get-settings.php @@ -38,8 +38,9 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => true, - 'idempotent' => true, + 'readonly' => true, + 'idempotent' => true, + 'instructions' => __( 'Call before stream/update-settings to read the current configuration so you can present a diff to the user. Setting keys follow the {section}_{field} convention (e.g. general_records_ttl).', 'stream' ), ); } diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index f2fbc998a..ca6aef159 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -38,8 +38,12 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => false, - 'destructive' => true, + 'readonly' => false, + 'destructive' => true, + // HTTP-idempotent: applying the same purge twice ends in the same state. + // WP REST router requires destructive AND idempotent to route to DELETE. + 'idempotent' => true, + 'instructions' => __( 'Permanently deletes log records matching the filters. ALWAYS run stream/get-records with the same filters first to show the user how many records will be removed, and require explicit confirmation. The ability also requires confirm=true in the input. There is no undo.', 'stream' ), ); } diff --git a/abilities/class-ability-update-settings.php b/abilities/class-ability-update-settings.php index a4c2d4a8a..ae4fb1c0f 100644 --- a/abilities/class-ability-update-settings.php +++ b/abilities/class-ability-update-settings.php @@ -38,8 +38,9 @@ public function get_description() { */ public function get_annotations() { return array( - 'readonly' => false, - 'idempotent' => true, + 'readonly' => false, + 'idempotent' => true, + 'instructions' => __( 'Apply a partial update to Stream settings. Always call stream/get-settings first so you can show the user the existing values, and require confirmation before changing keys that affect data retention (e.g. general_records_ttl).', 'stream' ), ); } diff --git a/classes/class-abilities.php b/classes/class-abilities.php index eeae4d4e7..3a0dab197 100644 --- a/classes/class-abilities.php +++ b/classes/class-abilities.php @@ -175,6 +175,12 @@ public function register_abilities() { } foreach ( $this->abilities as $ability ) { + // Defensive: skip if another loader instance already registered this + // ability (e.g. duplicate plugin instances in tests, multisite hooks). + // Re-registering would emit a _doing_it_wrong notice from core. + if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability->get_name() ) ) { + continue; + } $ability->register(); } } diff --git a/tests/phpunit/abilities/abilities-testcase.php b/tests/phpunit/abilities/abilities-testcase.php index cb03b3887..b168573a1 100644 --- a/tests/phpunit/abilities/abilities-testcase.php +++ b/tests/phpunit/abilities/abilities-testcase.php @@ -70,6 +70,84 @@ public function tearDown(): void { parent::tearDown(); } + /** + * Run a callback with the given action name pushed onto $wp_current_filter. + * + * WordPress 6.9 gates wp_register_ability() and wp_register_ability_category() + * behind a doing_action() check. Outside the natural wp_abilities_api_init / + * wp_abilities_api_categories_init action callbacks, registration triggers + * _doing_it_wrong. WP core's own tests work around this by manipulating + * $wp_current_filter directly; that's both faster and less polluting than + * round-tripping through add_action() + do_action(). + * + * @param string $action_name Action name to fake doing. + * @param callable $callback Callable to run inside the faked action. + * @return mixed Whatever $callback returns. + */ + protected function with_doing_action( $action_name, callable $callback ) { + global $wp_current_filter; + $wp_current_filter[] = $action_name; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + try { + return $callback(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Ensure the "stream" ability category is registered without going through + * the lazy registry-init action (which fires only once per process). + * + * @return void + */ + protected function ensure_stream_category_registered() { + if ( ! function_exists( 'wp_has_ability_category' ) || ! function_exists( 'wp_register_ability_category' ) ) { + return; + } + if ( wp_has_ability_category( Abilities::CATEGORY_SLUG ) ) { + return; + } + $this->with_doing_action( + 'wp_abilities_api_categories_init', + static function () { + wp_register_ability_category( + Abilities::CATEGORY_SLUG, + array( + 'label' => 'Stream', + 'description' => 'Stream test category.', + ) + ); + } + ); + } + + /** + * Register a Stream ability instance via wp_register_ability(), faking the + * required action context. Idempotent: returns silently if the ability is + * already registered (so tests can run in any order within a process). + * + * @param Ability $ability Ability instance to register. + * @return void + */ + protected function register_ability_in_test( Ability $ability ) { + $this->ensure_stream_category_registered(); + + if ( ! function_exists( 'wp_has_ability' ) ) { + return; + } + + if ( wp_has_ability( $ability->get_name() ) ) { + return; + } + + $this->with_doing_action( + 'wp_abilities_api_init', + static function () use ( $ability ) { + $ability->register(); + } + ); + } + /** * Validate a value against a JSON Schema using WP's REST validator. * diff --git a/tests/phpunit/abilities/test-rest-integration.php b/tests/phpunit/abilities/test-rest-integration.php new file mode 100644 index 000000000..8b121a494 --- /dev/null +++ b/tests/phpunit/abilities/test-rest-integration.php @@ -0,0 +1,293 @@ +server = $wp_rest_server; + do_action( 'rest_api_init' ); + + // Register the stream category and all 11 Stream abilities. The Abilities + // loader is the production code path; we just drive it with the + // $wp_current_filter trick so wp_register_ability_category() and + // wp_register_ability() pass their doing_action() guards. + $this->loader = new Abilities( $this->plugin ); + $this->loader->load_abilities(); + + global $wp_current_filter; + + if ( ! wp_has_ability_category( Abilities::CATEGORY_SLUG ) ) { + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $this->loader->register_category(); + array_pop( $wp_current_filter ); + } + + foreach ( $this->loader->abilities as $ability ) { + if ( wp_has_ability( $ability->get_name() ) ) { + continue; + } + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $ability->register(); + array_pop( $wp_current_filter ); + } + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + parent::tearDown(); + } + + /** + * Build the run-endpoint URL for a Stream ability slug. + * + * @param string $slug Ability slug (without "stream/" prefix). + * @return string Route path. + */ + private function run_url( $slug ) { + return '/wp-abilities/v1/abilities/stream/' . $slug . '/run'; + } + + // ------------------------------------------------------------------- + // Read-only ability: stream/get-records (GET). + // ------------------------------------------------------------------- + + public function test_get_records_returns_200_for_admin() { + wp_set_current_user( $this->admin_user_id ); + + $request = new \WP_REST_Request( 'GET', $this->run_url( 'get-records' ) ); + // Input must be passed even if empty: WP REST validates against the schema. + $request->set_query_params( array( 'input' => array() ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Body: ' . wp_json_encode( $response->get_data() ) ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'records', $data ); + $this->assertArrayHasKey( 'total', $data ); + } + + public function test_get_records_returns_403_for_subscriber() { + wp_set_current_user( $this->subscriber_user_id ); + + $request = new \WP_REST_Request( 'GET', $this->run_url( 'get-records' ) ); + $request->set_query_params( array( 'input' => array() ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_get_records_rejects_post_method() { + wp_set_current_user( $this->admin_user_id ); + + $request = new \WP_REST_Request( 'POST', $this->run_url( 'get-records' ) ); + $response = $this->server->dispatch( $request ); + + // WP core enforces GET for readonly abilities; POST returns 405. + $this->assertSame( 405, $response->get_status() ); + } + + // ------------------------------------------------------------------- + // Write ability: stream/create-alert (POST). + // ------------------------------------------------------------------- + + public function test_create_alert_returns_200_for_admin() { + wp_set_current_user( $this->admin_user_id ); + + $request = new \WP_REST_Request( 'POST', $this->run_url( 'create-alert' ) ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'alert_type' => 'highlight', + 'trigger_author' => 'any', + 'trigger_context' => 'any', + 'trigger_action' => 'any', + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Body: ' . wp_json_encode( $response->get_data() ) ); + $data = $response->get_data(); + $this->assertArrayHasKey( 'id', $data ); + $this->assertIsInt( $data['id'] ); + } + + public function test_create_alert_returns_403_for_subscriber() { + wp_set_current_user( $this->subscriber_user_id ); + + $request = new \WP_REST_Request( 'POST', $this->run_url( 'create-alert' ) ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'alert_type' => 'highlight', + 'trigger_author' => 'any', + 'trigger_context' => 'any', + 'trigger_action' => 'any', + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_create_alert_rejects_get_method() { + wp_set_current_user( $this->admin_user_id ); + + $request = new \WP_REST_Request( 'GET', $this->run_url( 'create-alert' ) ); + $response = $this->server->dispatch( $request ); + + // Write abilities require POST; GET returns 405. + $this->assertSame( 405, $response->get_status() ); + } + + // ------------------------------------------------------------------- + // Destructive ability: stream/purge-records (DELETE). + // ------------------------------------------------------------------- + + public function test_purge_records_returns_200_for_admin_with_filters() { + wp_set_current_user( $this->admin_user_id ); + + $request = new \WP_REST_Request( 'DELETE', $this->run_url( 'purge-records' ) ); + $request->set_query_params( + array( + 'input' => array( + 'confirm' => true, + 'older_than_days' => 365, + ), + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Body: ' . wp_json_encode( $response->get_data() ) ); + $data = $response->get_data(); + $this->assertArrayHasKey( 'deleted', $data ); + $this->assertGreaterThanOrEqual( 0, $data['deleted'] ); + } + + public function test_purge_records_returns_403_for_subscriber() { + wp_set_current_user( $this->subscriber_user_id ); + + $request = new \WP_REST_Request( 'DELETE', $this->run_url( 'purge-records' ) ); + $request->set_query_params( + array( + 'input' => array( + 'confirm' => true, + 'older_than_days' => 365, + ), + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_purge_records_rejects_post_method() { + wp_set_current_user( $this->admin_user_id ); + + $request = new \WP_REST_Request( 'POST', $this->run_url( 'purge-records' ) ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'confirm' => true, + 'older_than_days' => 365, + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + + // Destructive abilities require DELETE; POST returns 405. + $this->assertSame( 405, $response->get_status() ); + } + + // ------------------------------------------------------------------- + // Discovery: routes are listed and unknown abilities return 404. + // ------------------------------------------------------------------- + + /** + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_unknown_ability_returns_404() { + wp_set_current_user( $this->admin_user_id ); + + $request = new \WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/stream/no-such-ability/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + public function test_all_eleven_stream_abilities_appear_in_list_endpoint() { + wp_set_current_user( $this->admin_user_id ); + + $request = new \WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + + $names = array(); + foreach ( $data as $ability ) { + if ( isset( $ability['name'] ) ) { + $names[] = $ability['name']; + } + } + + foreach ( $this->loader->get_ability_slugs() as $slug ) { + $this->assertContains( 'stream/' . $slug, $names ); + } + } +} diff --git a/tests/phpunit/test-class-abilities.php b/tests/phpunit/test-class-abilities.php index ad9d4c796..6527cd894 100644 --- a/tests/phpunit/test-class-abilities.php +++ b/tests/phpunit/test-class-abilities.php @@ -125,15 +125,29 @@ public function test_register_abilities_loads_and_registers_when_action_fires() // Enable the setting so the constructor wires both category + abilities hooks. $this->plugin->settings->options['advanced_enable_abilities_api'] = 1; - remove_all_actions( 'wp_abilities_api_categories_init' ); - remove_all_actions( 'wp_abilities_api_init' ); $abilities = new Abilities( $this->plugin ); - // wp_get_ability() lazily fires both init actions. - $retrieved = wp_get_ability( 'stream/get-records' ); + // The registry is a process-wide singleton; once the lazy init actions have + // already fired in a prior test, wp_get_ability() won't refire them. Drive + // each registration step explicitly via $wp_current_filter so this test is + // deterministic regardless of ordering. + global $wp_current_filter; + + if ( ! wp_has_ability_category( Abilities::CATEGORY_SLUG ) ) { + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $abilities->register_category(); + array_pop( $wp_current_filter ); + } + + // Always exercise register_abilities() so this loader instance's abilities + // array gets populated regardless of whether the global registry already + // has them registered (a prior test in the same process may have done so). + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $abilities->register_abilities(); + array_pop( $wp_current_filter ); - $this->assertNotNull( $retrieved ); + $this->assertTrue( wp_has_ability( 'stream/get-records' ) ); $this->assertCount( 11, $abilities->abilities ); } } diff --git a/tests/phpunit/test-class-ability.php b/tests/phpunit/test-class-ability.php index 02a1bd4e8..e1ecf08ff 100644 --- a/tests/phpunit/test-class-ability.php +++ b/tests/phpunit/test-class-ability.php @@ -73,40 +73,45 @@ public function test_register_is_noop_without_wp_register_ability() { } public function test_register_makes_ability_retrievable() { - if ( ! function_exists( 'wp_register_ability' ) || ! function_exists( 'wp_get_ability' ) ) { + if ( ! function_exists( 'wp_register_ability' ) || ! function_exists( 'wp_has_ability' ) ) { $this->markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); } - // Both wp_register_ability_category() and wp_register_ability() are gated on - // doing_action(...); fire each action manually with our callbacks hooked. This - // also works after another test in the suite has already booted the registry, - // because the doing_action() check just looks at the current action stack. - add_action( - 'wp_abilities_api_categories_init', - static function () { - if ( null === wp_get_ability_category( Abilities::CATEGORY_SLUG ) ) { - wp_register_ability_category( - Abilities::CATEGORY_SLUG, - array( - 'label' => 'Stream', - 'description' => 'Stream test category.', - ) - ); - } - } - ); - do_action( 'wp_abilities_api_categories_init' ); - - $ability = $this->ability; - add_action( - 'wp_abilities_api_init', - static function () use ( $ability ) { - $ability->register(); - } - ); - do_action( 'wp_abilities_api_init' ); + // WP 6.9 gates wp_register_ability() and wp_register_ability_category() on + // doing_action(); manipulate $wp_current_filter directly to satisfy the guard + // without polluting the global hook registry. Same pattern WP core uses in + // tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php. + global $wp_current_filter; + + if ( ! wp_has_ability_category( Abilities::CATEGORY_SLUG ) ) { + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + wp_register_ability_category( + Abilities::CATEGORY_SLUG, + array( + 'label' => 'Stream', + 'description' => 'Stream test category.', + ) + ); + array_pop( $wp_current_filter ); + } - $registered = wp_get_ability( 'stream/fake' ); - $this->assertNotNull( $registered ); + if ( ! wp_has_ability( 'stream/fake' ) ) { + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $this->ability->register(); + array_pop( $wp_current_filter ); + } + + $this->assertTrue( wp_has_ability( 'stream/fake' ) ); + $this->assertInstanceOf( '\WP_Ability', wp_get_ability( 'stream/fake' ) ); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void { + if ( function_exists( 'wp_has_ability' ) && wp_has_ability( 'stream/fake' ) ) { + wp_unregister_ability( 'stream/fake' ); + } + parent::tearDown(); } } From aeb4ff584339d88dac4e4bac072e8807a1537ca5 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 17:04:20 +0530 Subject: [PATCH 09/24] Default $input to null in Ability::execute() signatures WP core's WP_Ability::invoke_callback() spreads zero arguments into the execute callback when the ability declares no input_schema (see wp-includes/abilities-api/class-wp-ability.php:506-512). Our previous 'execute($input)' signature required one argument, so any GET request to a no-input-schema ability raised a fatal ArgumentCountError and returned HTTP 500 to the caller. Add '$input = null' as the default on the abstract Ability::execute() plus all 11 concrete subclasses and the test fake. Null matches WP core's own conventions (their invoke_callback and check_permissions both default $input to null). Abilities that DO declare an input_schema continue to receive the parsed value verbatim from core, so the default sits unused for those. Caught by live e2e testing against WP 6.9.1 (Phase 4 of XWPENG-13-e2e.md): get-settings, get-connectors, and get-exclusion-rules previously fataled. --- abilities/class-ability-create-alert.php | 2 +- abilities/class-ability-create-exclusion-rule.php | 2 +- abilities/class-ability-delete-alert.php | 2 +- abilities/class-ability-get-alerts.php | 2 +- abilities/class-ability-get-connectors.php | 2 +- abilities/class-ability-get-exclusion-rules.php | 2 +- abilities/class-ability-get-record.php | 2 +- abilities/class-ability-get-records.php | 2 +- abilities/class-ability-get-settings.php | 2 +- abilities/class-ability-purge-records.php | 2 +- abilities/class-ability-update-settings.php | 2 +- classes/class-ability.php | 11 +++++++++-- tests/phpunit/fake-ability.php | 2 +- 13 files changed, 21 insertions(+), 14 deletions(-) diff --git a/abilities/class-ability-create-alert.php b/abilities/class-ability-create-alert.php index 8792c6d73..03aa79d83 100644 --- a/abilities/class-ability-create-alert.php +++ b/abilities/class-ability-create-alert.php @@ -109,7 +109,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { $status = isset( $input['status'] ) ? $input['status'] : 'wp_stream_enabled'; $post_id = wp_insert_post( diff --git a/abilities/class-ability-create-exclusion-rule.php b/abilities/class-ability-create-exclusion-rule.php index 649355994..6e18023a2 100644 --- a/abilities/class-ability-create-exclusion-rule.php +++ b/abilities/class-ability-create-exclusion-rule.php @@ -119,7 +119,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { $option_key = $this->plugin->settings->option_key; $options = (array) get_option( $option_key, array() ); diff --git a/abilities/class-ability-delete-alert.php b/abilities/class-ability-delete-alert.php index 3776c8ac8..5f52912b6 100644 --- a/abilities/class-ability-delete-alert.php +++ b/abilities/class-ability-delete-alert.php @@ -80,7 +80,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { $id = isset( $input['id'] ) ? (int) $input['id'] : 0; $post = $id > 0 ? get_post( $id ) : null; diff --git a/abilities/class-ability-get-alerts.php b/abilities/class-ability-get-alerts.php index 1c0c85974..816522cb3 100644 --- a/abilities/class-ability-get-alerts.php +++ b/abilities/class-ability-get-alerts.php @@ -92,7 +92,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { $requested = isset( $input['status'] ) ? $input['status'] : 'any'; switch ( $requested ) { diff --git a/abilities/class-ability-get-connectors.php b/abilities/class-ability-get-connectors.php index 52b51b2b3..ef2262a1b 100644 --- a/abilities/class-ability-get-connectors.php +++ b/abilities/class-ability-get-connectors.php @@ -88,7 +88,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { unset( $input ); $out = array(); diff --git a/abilities/class-ability-get-exclusion-rules.php b/abilities/class-ability-get-exclusion-rules.php index e334e0456..e2d097c0f 100644 --- a/abilities/class-ability-get-exclusion-rules.php +++ b/abilities/class-ability-get-exclusion-rules.php @@ -68,7 +68,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { unset( $input ); $options = (array) $this->plugin->settings->options; diff --git a/abilities/class-ability-get-record.php b/abilities/class-ability-get-record.php index 35307de09..a3c7d823b 100644 --- a/abilities/class-ability-get-record.php +++ b/abilities/class-ability-get-record.php @@ -93,7 +93,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { global $wpdb; $id = isset( $input['id'] ) ? (int) $input['id'] : 0; diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php index f9a891899..9aef16c99 100644 --- a/abilities/class-ability-get-records.php +++ b/abilities/class-ability-get-records.php @@ -192,7 +192,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { $input = (array) $input; $allowed_keys = array( diff --git a/abilities/class-ability-get-settings.php b/abilities/class-ability-get-settings.php index 1ebcd86c8..8955c33a9 100644 --- a/abilities/class-ability-get-settings.php +++ b/abilities/class-ability-get-settings.php @@ -65,7 +65,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { unset( $input ); return (array) $this->plugin->settings->options; diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index ca6aef159..a746d9741 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -102,7 +102,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { global $wpdb; if ( empty( $input['confirm'] ) ) { diff --git a/abilities/class-ability-update-settings.php b/abilities/class-ability-update-settings.php index ae4fb1c0f..de0851dae 100644 --- a/abilities/class-ability-update-settings.php +++ b/abilities/class-ability-update-settings.php @@ -77,7 +77,7 @@ public function get_output_schema() { /** * {@inheritDoc} */ - public function execute( $input ) { + public function execute( $input = null ) { $option_key = $this->plugin->settings->option_key; $current = (array) get_option( $option_key, array() ); $updates = isset( $input['settings'] ) ? (array) $input['settings'] : array(); diff --git a/classes/class-ability.php b/classes/class-ability.php index b72e8ffd7..68f3cac6f 100644 --- a/classes/class-ability.php +++ b/classes/class-ability.php @@ -71,10 +71,17 @@ abstract public function get_output_schema(); /** * Execute the ability. * - * @param array $input Validated input matching get_input_schema(). + * The default value is `null` to match WP core's invoke_callback() contract: + * when an ability has no input_schema, core calls the callback with zero + * arguments. PHP's ArgumentCountError would otherwise fatal here. Subclasses + * that declare a non-empty input_schema can rely on $input being a parsed + * array (core enforces this via rest_validate_value_from_schema()). + * + * @param mixed $input Validated input matching get_input_schema(), or null + * when the ability declares no input_schema. * @return mixed|\WP_Error Result conforming to get_output_schema(), or WP_Error. */ - abstract public function execute( $input ); + abstract public function execute( $input = null ); /** * Permission check. Defaults to manage_options; override per ability. diff --git a/tests/phpunit/fake-ability.php b/tests/phpunit/fake-ability.php index 7e320bc23..0f3d00643 100644 --- a/tests/phpunit/fake-ability.php +++ b/tests/phpunit/fake-ability.php @@ -82,7 +82,7 @@ public function get_annotations() { * @param array $input Input matching get_input_schema(). * @return string */ - public function execute( $input ) { + public function execute( $input = null ) { $this->last_input = $input; return 'ok'; } From 8a270ecfa648641071180947b39a7b5779288658 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 17:58:58 +0530 Subject: [PATCH 10/24] Harden Abilities API against critical CR findings - Authorization: read abilities use 'view_stream'; base default uses WP_STREAM_SETTINGS_CAPABILITY. Abilities registers a user_has_cap filter for REST contexts where Admin (and its filter) isn't loaded, so allowed roles can call read abilities consistently with the UI. - update-settings: allowlist {section}_{field} keys from registered fields, run incoming values through Settings::sanitize_settings(), reject payloads with no recognized keys. - create-exclusion-rule: schema gains format:ip and maxLength bounds; execute() sanitizes via sanitize_text_field(), validates IPs with FILTER_VALIDATE_IP, validates connector against registered slugs, rejects all-empty payloads. - purge-records: use rows_affected from the DELETE itself (no stale pre-count); run orphan-meta sweep after; fix MySQL alias syntax. - get-record: kept direct query (Query::query has a real array_shift bug with record__in) but adds explicit blog_id scoping on multisite so cross-site record leakage cannot occur. --- .../class-ability-create-exclusion-rule.php | 66 ++++++++++++++++++- abilities/class-ability-get-alerts.php | 11 ++++ abilities/class-ability-get-connectors.php | 11 ++++ .../class-ability-get-exclusion-rules.php | 11 ++++ abilities/class-ability-get-record.php | 36 ++++++++-- abilities/class-ability-get-records.php | 11 ++++ abilities/class-ability-purge-records.php | 29 ++++---- abilities/class-ability-update-settings.php | 32 ++++++++- classes/class-abilities.php | 61 +++++++++++++++++ classes/class-ability.php | 6 +- ...st-class-ability-create-exclusion-rule.php | 44 +++++++++++++ .../test-class-ability-update-settings.php | 39 ++++++++++- 12 files changed, 331 insertions(+), 26 deletions(-) diff --git a/abilities/class-ability-create-exclusion-rule.php b/abilities/class-ability-create-exclusion-rule.php index 6e18023a2..d5efe2af3 100644 --- a/abilities/class-ability-create-exclusion-rule.php +++ b/abilities/class-ability-create-exclusion-rule.php @@ -67,23 +67,29 @@ public function get_input_schema() { 'properties' => array( 'author_or_role' => array( 'type' => 'string', - 'description' => 'User ID or role slug. Empty string matches anything.', + 'description' => 'User ID or role slug.', + 'maxLength' => 100, ), 'connector' => array( 'type' => 'string', - 'description' => 'Connector slug (e.g. "posts", "users").', + 'description' => 'Connector slug (e.g. "posts", "users"). Validated against registered connectors.', + 'maxLength' => 100, ), 'context' => array( 'type' => 'string', 'description' => 'Context slug under the connector.', + 'maxLength' => 100, ), 'action' => array( 'type' => 'string', 'description' => 'Action slug (e.g. "updated", "deleted").', + 'maxLength' => 100, ), 'ip_address' => array( 'type' => 'string', 'description' => 'Client IP address (IPv4 or IPv6).', + 'format' => 'ip', + 'maxLength' => 45, ), ), ); @@ -120,6 +126,60 @@ public function get_output_schema() { * {@inheritDoc} */ public function execute( $input = null ) { + $input = (array) $input; + + // Sanitize each incoming value; reject all-empty payloads so we never + // store a no-op rule that would silently match nothing (or, depending + // on Log::record_excluded() semantics, everything). + $sanitized = array(); + foreach ( self::RULE_COLUMNS as $column ) { + $raw = isset( $input[ $column ] ) ? (string) $input[ $column ] : ''; + $sanitized[ $column ] = sanitize_text_field( $raw ); + } + + // JSON Schema's format:ip is a hint, not enforced by rest_validate_value_from_schema. + // Validate explicitly so bogus IPs never reach storage. + if ( '' !== $sanitized['ip_address'] && ! filter_var( $sanitized['ip_address'], FILTER_VALIDATE_IP ) ) { + return new \WP_Error( + 'stream_invalid_ip', + __( 'ip_address must be a valid IPv4 or IPv6 address.', 'stream' ), + array( 'status' => 400 ) + ); + } + + // Validate connector against registered connectors when provided. + if ( '' !== $sanitized['connector'] ) { + $known = isset( $this->plugin->connectors->connectors ) + ? array_keys( (array) $this->plugin->connectors->connectors ) + : array(); + if ( ! empty( $known ) && ! in_array( $sanitized['connector'], $known, true ) ) { + return new \WP_Error( + 'stream_unknown_connector', + /* translators: %s: connector slug */ + sprintf( __( 'Unknown connector: %s. Use stream/get-connectors to list valid slugs.', 'stream' ), $sanitized['connector'] ), + array( 'status' => 400 ) + ); + } + } + + // Reject payloads where every filter is empty after sanitization. The + // JSON Schema minProperties:1 only ensures *a* key was supplied; this + // guards against {"author_or_role": ""}. + $has_value = false; + foreach ( $sanitized as $value ) { + if ( '' !== $value ) { + $has_value = true; + break; + } + } + if ( ! $has_value ) { + return new \WP_Error( + 'stream_empty_rule', + __( 'At least one filter value must be non-empty.', 'stream' ), + array( 'status' => 400 ) + ); + } + $option_key = $this->plugin->settings->option_key; $options = (array) get_option( $option_key, array() ); @@ -142,7 +202,7 @@ public function execute( $input = null ) { $rule = array(); foreach ( self::RULE_COLUMNS as $column ) { - $value = isset( $input[ $column ] ) ? (string) $input[ $column ] : ''; + $value = $sanitized[ $column ]; $rules[ $column ][ $index ] = $value; $rule[ $column ] = '' === $value ? null : $value; } diff --git a/abilities/class-ability-get-alerts.php b/abilities/class-ability-get-alerts.php index 816522cb3..2d1908629 100644 --- a/abilities/class-ability-get-alerts.php +++ b/abilities/class-ability-get-alerts.php @@ -44,6 +44,17 @@ public function get_annotations() { ); } + /** + * {@inheritDoc} + * + * Read abilities use Stream's view capability so editors / other allowed + * roles can call them, matching the admin UI's record-viewing permissions. + */ + public function permission_callback( $input = array() ) { + unset( $input ); + return current_user_can( 'view_stream' ); + } + /** * {@inheritDoc} */ diff --git a/abilities/class-ability-get-connectors.php b/abilities/class-ability-get-connectors.php index ef2262a1b..7aedbc62e 100644 --- a/abilities/class-ability-get-connectors.php +++ b/abilities/class-ability-get-connectors.php @@ -44,6 +44,17 @@ public function get_annotations() { ); } + /** + * {@inheritDoc} + * + * Read abilities use Stream's view capability so editors / other allowed + * roles can call them, matching the admin UI's record-viewing permissions. + */ + public function permission_callback( $input = array() ) { + unset( $input ); + return current_user_can( 'view_stream' ); + } + /** * {@inheritDoc} */ diff --git a/abilities/class-ability-get-exclusion-rules.php b/abilities/class-ability-get-exclusion-rules.php index e2d097c0f..5c68d5a7b 100644 --- a/abilities/class-ability-get-exclusion-rules.php +++ b/abilities/class-ability-get-exclusion-rules.php @@ -44,6 +44,17 @@ public function get_annotations() { ); } + /** + * {@inheritDoc} + * + * Read abilities use Stream's view capability so editors / other allowed + * roles can call them, matching the admin UI's record-viewing permissions. + */ + public function permission_callback( $input = array() ) { + unset( $input ); + return current_user_can( 'view_stream' ); + } + /** * {@inheritDoc} */ diff --git a/abilities/class-ability-get-record.php b/abilities/class-ability-get-record.php index a3c7d823b..f5ecd3f5b 100644 --- a/abilities/class-ability-get-record.php +++ b/abilities/class-ability-get-record.php @@ -44,6 +44,17 @@ public function get_annotations() { ); } + /** + * {@inheritDoc} + * + * Read abilities use Stream's view capability so editors / other allowed + * roles can call them, matching the admin UI's record-viewing permissions. + */ + public function permission_callback( $input = array() ) { + unset( $input ); + return current_user_can( 'view_stream' ); + } + /** * {@inheritDoc} */ @@ -98,12 +109,25 @@ public function execute( $input = null ) { $id = isset( $input['id'] ) ? (int) $input['id'] : 0; - // Stream's Query class doesn't expose a single-ID filter, so query the table directly. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $row = $wpdb->get_row( - $wpdb->prepare( "SELECT * FROM {$wpdb->stream} WHERE ID = %d", $id ), - ARRAY_A - ); + // Stream's Query class doesn't expose a single-ID filter (record__in + // is broken for one-element arrays due to array_shift() in + // Query::query()), so query the table directly. We add an explicit + // blog_id filter on multisite so the response can never leak a record + // from another site on a network install — the admin records page is + // scoped per-site and abilities must match. + $where = ''; + $prepared = array( $id ); + if ( is_multisite() && ! $this->plugin->is_network_activated() ) { + $where = ' AND blog_id = %d'; + $prepared[] = get_current_blog_id(); + } + + // $wpdb->stream and {$where} are constructed from string literals in this + // method (no user input), and $prepared holds only an int id and (on + // multisite) the integer blog id from get_current_blog_id(). + $sql = "SELECT * FROM {$wpdb->stream} WHERE ID = %d{$where}"; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + $row = $wpdb->get_row( $wpdb->prepare( $sql, $prepared ), ARRAY_A ); if ( empty( $row ) ) { return new \WP_Error( diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php index 9aef16c99..a3c43d2fb 100644 --- a/abilities/class-ability-get-records.php +++ b/abilities/class-ability-get-records.php @@ -58,6 +58,17 @@ public function get_annotations() { ); } + /** + * {@inheritDoc} + * + * Read abilities use Stream's view capability so editors / other allowed + * roles can call them, matching the admin UI's record-viewing permissions. + */ + public function permission_callback( $input = array() ) { + unset( $input ); + return current_user_can( 'view_stream' ); + } + /** * {@inheritDoc} */ diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index a746d9741..ce91996b8 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -144,23 +144,30 @@ public function execute( $input = null ) { $where_sql = implode( ' AND ', $where ); - // Count first so the response is meaningful even if the cascade DELETE returns the - // combined affected rows from both tables. By the time we reach this point at least - // one filter has been added (the count( $where ) === 1 guard above ensures $params - // is non-empty), so $wpdb->prepare() is always called with placeholders. + // Delete stream rows first and capture rows_affected so the response reflects + // the actual count (rather than a stale pre-DELETE COUNT). $params is guaranteed + // non-empty here by the count( $where ) === 1 guard above. MySQL requires the + // "DELETE alias FROM tbl AS alias" form when the WHERE references an alias. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery - $count_sql = "SELECT COUNT(*) FROM {$wpdb->stream} AS stream WHERE {$where_sql}"; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $deleted = (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $delete_sql = "DELETE stream FROM {$wpdb->stream} AS stream WHERE {$where_sql}"; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( $wpdb->prepare( $delete_sql, $params ) ); + $deleted = (int) $wpdb->rows_affected; if ( 0 === $deleted ) { return array( 'deleted' => 0 ); } - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery - $delete_sql = "DELETE stream, meta FROM {$wpdb->stream} AS stream LEFT JOIN {$wpdb->streammeta} AS meta ON meta.record_id = stream.ID WHERE {$where_sql}"; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery - $wpdb->query( $wpdb->prepare( $delete_sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + // Sweep orphaned meta rows whose parent record was just deleted. Idempotent + // and safe to run unconditionally; the LEFT JOIN scopes the cleanup to + // orphans across the whole streammeta table, which also catches any prior + // orphans without growing this query's blast radius beyond a single sweep. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( + "DELETE meta FROM {$wpdb->streammeta} AS meta + LEFT JOIN {$wpdb->stream} AS stream ON stream.ID = meta.record_id + WHERE stream.ID IS NULL" + ); return array( 'deleted' => $deleted ); } diff --git a/abilities/class-ability-update-settings.php b/abilities/class-ability-update-settings.php index de0851dae..4734b75a1 100644 --- a/abilities/class-ability-update-settings.php +++ b/abilities/class-ability-update-settings.php @@ -55,7 +55,7 @@ public function get_input_schema() { 'properties' => array( 'settings' => array( 'type' => 'object', - 'description' => 'Partial settings map keyed by {section}_{field}. Values overwrite existing entries; omitted keys are preserved.', + 'description' => 'Partial settings map keyed by {section}_{field} (e.g. general_records_ttl). Unknown keys are rejected; values are normalized through Stream\'s settings sanitizer. Omitted keys are preserved.', 'additionalProperties' => true, 'minProperties' => 1, ), @@ -82,7 +82,35 @@ public function execute( $input = null ) { $current = (array) get_option( $option_key, array() ); $updates = isset( $input['settings'] ) ? (array) $input['settings'] : array(); - $merged = array_merge( $current, $updates ); + // Build allowlist of {section}_{field} keys from registered settings. + $valid_keys = array(); + foreach ( $this->plugin->settings->get_fields() as $section => $section_data ) { + if ( empty( $section_data['fields'] ) || ! is_array( $section_data['fields'] ) ) { + continue; + } + foreach ( $section_data['fields'] as $field ) { + if ( ! empty( $field['name'] ) ) { + $valid_keys[] = $section . '_' . $field['name']; + } + } + } + + // Drop unknown keys before sanitization so callers fail fast. + $filtered = array_intersect_key( $updates, array_flip( $valid_keys ) ); + if ( empty( $filtered ) ) { + return new \WP_Error( + 'stream_no_valid_settings', + __( 'No recognized setting keys were provided. Setting keys follow the {section}_{field} convention.', 'stream' ), + array( 'status' => 400 ) + ); + } + + // Run only the incoming keys through Stream's sanitize pipeline so + // values are normalized to their declared field type, then merge over + // the existing options so unrelated keys are preserved. + $sanitized = $this->plugin->settings->sanitize_settings( $filtered ); + $merged = array_merge( $current, $sanitized ); + update_option( $option_key, $merged ); // Refresh in-memory copy so subsequent abilities see the change. diff --git a/classes/class-abilities.php b/classes/class-abilities.php index 3a0dab197..e8ff7204b 100644 --- a/classes/class-abilities.php +++ b/classes/class-abilities.php @@ -65,6 +65,67 @@ public function __construct( Plugin $plugin ) { add_action( 'wp_abilities_api_categories_init', array( $this, 'register_category' ) ); add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + + // REST requests don't load Admin, so the dynamic view_stream cap filter + // isn't registered. Register an equivalent here so read abilities can + // authorize editors / other allowed roles consistently with the admin UI. + if ( ! isset( $this->plugin->admin ) ) { + add_filter( 'user_has_cap', array( $this, 'filter_user_caps' ), 10, 4 ); + } + } + + /** + * Grant the dynamic view_stream cap to users whose role appears in + * general_role_access. Mirrors Admin::filter_user_caps() for REST contexts + * where Admin isn't instantiated. + * + * @param array $allcaps All capabilities. + * @param array $caps Required caps. + * @param array $args Unused. + * @param \WP_User $user User. + * @return array + */ + public function filter_user_caps( $allcaps, $caps, $args, $user = null ) { + unset( $args ); + + if ( ! in_array( 'view_stream', (array) $caps, true ) ) { + return $allcaps; + } + + $role_access = isset( $this->plugin->settings->options['general_role_access'] ) + ? (array) $this->plugin->settings->options['general_role_access'] + : array(); + + if ( empty( $role_access ) ) { + return $allcaps; + } + + $user = is_a( $user, '\WP_User' ) ? $user : wp_get_current_user(); + if ( ! $user || ! $user->exists() ) { + return $allcaps; + } + + global $wp_roles; + $_wp_roles = isset( $wp_roles ) ? $wp_roles : new \WP_Roles(); + + $roles = array_unique( + array_merge( + $user->roles, + array_filter( + array_keys( $user->caps ), + array( $_wp_roles, 'is_role' ) + ) + ) + ); + + foreach ( $roles as $role ) { + if ( in_array( $role, $role_access, true ) ) { + $allcaps['view_stream'] = true; + break; + } + } + + return $allcaps; } /** diff --git a/classes/class-ability.php b/classes/class-ability.php index 68f3cac6f..fc7394011 100644 --- a/classes/class-ability.php +++ b/classes/class-ability.php @@ -84,14 +84,16 @@ abstract public function get_output_schema(); abstract public function execute( $input = null ); /** - * Permission check. Defaults to manage_options; override per ability. + * Permission check. Defaults to the Stream settings capability so write + * abilities are safe by default; read-only abilities should override with + * the view capability (e.g. 'view_stream') to follow least-privilege. * * @param array $input Input that will be passed to execute(). * @return bool|\WP_Error */ public function permission_callback( $input = array() ) { unset( $input ); - return current_user_can( 'manage_options' ); + return current_user_can( WP_STREAM_SETTINGS_CAPABILITY ); } /** diff --git a/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php b/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php index a1f2e2c8a..71111283e 100644 --- a/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php +++ b/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php @@ -94,4 +94,48 @@ public function test_schema_rejects_empty_input() { $result = rest_validate_value_from_schema( array(), $this->ability->get_input_schema() ); $this->assertWPError( $result ); } + + public function test_rejects_invalid_ip_address() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( array( 'ip_address' => 'not-an-ip' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'stream_invalid_ip', $result->get_error_code() ); + } + + public function test_rejects_unknown_connector() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( array( 'connector' => 'definitely-not-a-real-connector' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'stream_unknown_connector', $result->get_error_code() ); + } + + public function test_rejects_all_empty_values() { + wp_set_current_user( $this->admin_user_id ); + + // minProperties:1 in the schema is satisfied by the key being present, + // but execute() must reject when every filter is the empty string. + $result = $this->ability->execute( array( 'author_or_role' => '' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'stream_empty_rule', $result->get_error_code() ); + } + + public function test_sanitizes_text_input() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( + array( + 'author_or_role' => "evil\nmultiline", + ) + ); + + $this->assertIsArray( $result ); + // sanitize_text_field strips tags and normalizes whitespace. + $this->assertStringNotContainsString( '', + ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( 45, $result['general_records_ttl'] ); + $this->assertArrayNotHasKey( 'malicious_key', $result ); + + $stored = (array) get_option( $option_key ); + $this->assertArrayNotHasKey( 'malicious_key', $stored ); } } From 16ffa0f1043050bcd58222a38ddc40147e145676 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 18:23:20 +0530 Subject: [PATCH 11/24] Extract Trait_View_Stream_Permission for read abilities The 5 read-only abilities (get-records, get-record, get-alerts, get-connectors, get-exclusion-rules) all carried an identical permission_callback() returning current_user_can( 'view_stream' ) with the same rationale docblock. Move it into a shared trait so the authorization rule lives in one place. Each ability file require_once's the trait directly so per-test loaders (which require ability files individually) keep working without any autoloader changes. Net -35 LOC. Single-site and multisite Ability suites unchanged: 316 tests pass with the same skipped/incomplete counts as before. --- abilities/class-ability-get-alerts.php | 15 ++++-------- abilities/class-ability-get-connectors.php | 15 ++++-------- .../class-ability-get-exclusion-rules.php | 15 ++++-------- abilities/class-ability-get-record.php | 15 ++++-------- abilities/class-ability-get-records.php | 15 ++++-------- abilities/trait-view-stream-permission.php | 24 +++++++++++++++++++ 6 files changed, 44 insertions(+), 55 deletions(-) create mode 100644 abilities/trait-view-stream-permission.php diff --git a/abilities/class-ability-get-alerts.php b/abilities/class-ability-get-alerts.php index 2d1908629..c9667b18c 100644 --- a/abilities/class-ability-get-alerts.php +++ b/abilities/class-ability-get-alerts.php @@ -7,11 +7,15 @@ namespace WP_Stream; +require_once __DIR__ . '/trait-view-stream-permission.php'; + /** * Class - Ability_Get_Alerts */ class Ability_Get_Alerts extends Ability { + use Trait_View_Stream_Permission; + /** * {@inheritDoc} */ @@ -44,17 +48,6 @@ public function get_annotations() { ); } - /** - * {@inheritDoc} - * - * Read abilities use Stream's view capability so editors / other allowed - * roles can call them, matching the admin UI's record-viewing permissions. - */ - public function permission_callback( $input = array() ) { - unset( $input ); - return current_user_can( 'view_stream' ); - } - /** * {@inheritDoc} */ diff --git a/abilities/class-ability-get-connectors.php b/abilities/class-ability-get-connectors.php index 7aedbc62e..22baef143 100644 --- a/abilities/class-ability-get-connectors.php +++ b/abilities/class-ability-get-connectors.php @@ -7,11 +7,15 @@ namespace WP_Stream; +require_once __DIR__ . '/trait-view-stream-permission.php'; + /** * Class - Ability_Get_Connectors */ class Ability_Get_Connectors extends Ability { + use Trait_View_Stream_Permission; + /** * {@inheritDoc} */ @@ -44,17 +48,6 @@ public function get_annotations() { ); } - /** - * {@inheritDoc} - * - * Read abilities use Stream's view capability so editors / other allowed - * roles can call them, matching the admin UI's record-viewing permissions. - */ - public function permission_callback( $input = array() ) { - unset( $input ); - return current_user_can( 'view_stream' ); - } - /** * {@inheritDoc} */ diff --git a/abilities/class-ability-get-exclusion-rules.php b/abilities/class-ability-get-exclusion-rules.php index 5c68d5a7b..8c77aba15 100644 --- a/abilities/class-ability-get-exclusion-rules.php +++ b/abilities/class-ability-get-exclusion-rules.php @@ -7,11 +7,15 @@ namespace WP_Stream; +require_once __DIR__ . '/trait-view-stream-permission.php'; + /** * Class - Ability_Get_Exclusion_Rules */ class Ability_Get_Exclusion_Rules extends Ability { + use Trait_View_Stream_Permission; + /** * {@inheritDoc} */ @@ -44,17 +48,6 @@ public function get_annotations() { ); } - /** - * {@inheritDoc} - * - * Read abilities use Stream's view capability so editors / other allowed - * roles can call them, matching the admin UI's record-viewing permissions. - */ - public function permission_callback( $input = array() ) { - unset( $input ); - return current_user_can( 'view_stream' ); - } - /** * {@inheritDoc} */ diff --git a/abilities/class-ability-get-record.php b/abilities/class-ability-get-record.php index f5ecd3f5b..bfbb32887 100644 --- a/abilities/class-ability-get-record.php +++ b/abilities/class-ability-get-record.php @@ -7,11 +7,15 @@ namespace WP_Stream; +require_once __DIR__ . '/trait-view-stream-permission.php'; + /** * Class - Ability_Get_Record */ class Ability_Get_Record extends Ability { + use Trait_View_Stream_Permission; + /** * {@inheritDoc} */ @@ -44,17 +48,6 @@ public function get_annotations() { ); } - /** - * {@inheritDoc} - * - * Read abilities use Stream's view capability so editors / other allowed - * roles can call them, matching the admin UI's record-viewing permissions. - */ - public function permission_callback( $input = array() ) { - unset( $input ); - return current_user_can( 'view_stream' ); - } - /** * {@inheritDoc} */ diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php index a3c43d2fb..ccda2cd55 100644 --- a/abilities/class-ability-get-records.php +++ b/abilities/class-ability-get-records.php @@ -7,11 +7,15 @@ namespace WP_Stream; +require_once __DIR__ . '/trait-view-stream-permission.php'; + /** * Class - Ability_Get_Records */ class Ability_Get_Records extends Ability { + use Trait_View_Stream_Permission; + /** * Maximum records returned in a single call. * @@ -58,17 +62,6 @@ public function get_annotations() { ); } - /** - * {@inheritDoc} - * - * Read abilities use Stream's view capability so editors / other allowed - * roles can call them, matching the admin UI's record-viewing permissions. - */ - public function permission_callback( $input = array() ) { - unset( $input ); - return current_user_can( 'view_stream' ); - } - /** * {@inheritDoc} */ diff --git a/abilities/trait-view-stream-permission.php b/abilities/trait-view-stream-permission.php new file mode 100644 index 000000000..840c0541b --- /dev/null +++ b/abilities/trait-view-stream-permission.php @@ -0,0 +1,24 @@ + Date: Mon, 4 May 2026 18:32:25 +0530 Subject: [PATCH 12/24] Tighten get-records schema: orderby enum, __in maxItems - orderby: add enum bound to Query::query()'s actual sortable columns, and change the default from 'date' (not a real Stream column; silently fell back to ID) to 'created'. This makes the silent fallback impossible at the schema layer for REST callers and surfaces the contract for direct PHP callers. - user_id__in / connector__in: add maxItems: 100 so a caller cannot force an unbounded IN(...) clause from a single request. Tests cover schema shape, REST schema validation (orderby=date now rejected, 101 items rejected), and a behavioral regression that seeds two records with out-of-order created/ID and asserts orderby=created ASC actually orders by created -- not by ID, which is what the old silent fallback was doing. --- abilities/class-ability-get-records.php | 23 +++- .../test-class-ability-get-records.php | 105 ++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php index ccda2cd55..c450da7a8 100644 --- a/abilities/class-ability-get-records.php +++ b/abilities/class-ability-get-records.php @@ -96,7 +96,8 @@ public function get_input_schema() { 'user_id__in' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), - 'description' => 'Match any of these user IDs.', + 'maxItems' => 100, + 'description' => 'Match any of these user IDs (max 100).', ), 'user_role' => array( 'type' => 'string', @@ -109,7 +110,8 @@ public function get_input_schema() { 'connector__in' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), - 'description' => 'Match any of these connector slugs.', + 'maxItems' => 100, + 'description' => 'Match any of these connector slugs (max 100).', ), 'context' => array( 'type' => 'string', @@ -148,8 +150,21 @@ public function get_input_schema() { ), 'orderby' => array( 'type' => 'string', - 'description' => 'Column to order by.', - 'default' => 'date', + 'description' => 'Column to order by. Must be one of Stream\'s sortable columns; unknown values fall back to ID in Query::query().', + 'enum' => array( + 'ID', + 'created', + 'user_id', + 'user_role', + 'summary', + 'connector', + 'context', + 'action', + 'site_id', + 'blog_id', + 'object_id', + ), + 'default' => 'created', ), ), ); diff --git a/tests/phpunit/abilities/test-class-ability-get-records.php b/tests/phpunit/abilities/test-class-ability-get-records.php index c7a801248..e8cf0b1b4 100644 --- a/tests/phpunit/abilities/test-class-ability-get-records.php +++ b/tests/phpunit/abilities/test-class-ability-get-records.php @@ -115,4 +115,109 @@ public function test_execute_strips_unknown_input_keys() { $this->assertIsArray( $result ); $this->assertArrayHasKey( 'records', $result ); } + + public function test_orderby_schema_uses_query_allowlist() { + $schema = $this->ability->get_input_schema(); + $orderby = $schema['properties']['orderby']; + + $this->assertArrayHasKey( 'enum', $orderby, 'orderby must enumerate allowed columns so callers cannot silently fall back to ID.' ); + $this->assertContains( 'created', $orderby['enum'] ); + $this->assertContains( 'ID', $orderby['enum'] ); + $this->assertNotContains( 'date', $orderby['enum'], '"date" is not a real Stream column; the previous default silently fell back to ID.' ); + $this->assertSame( 'created', $orderby['default'] ); + } + + public function test_in_arrays_have_max_items_bound() { + $schema = $this->ability->get_input_schema(); + + $this->assertSame( 100, $schema['properties']['user_id__in']['maxItems'] ); + $this->assertSame( 100, $schema['properties']['connector__in']['maxItems'] ); + } + + public function test_input_schema_rejects_unknown_orderby() { + $schema = $this->ability->get_input_schema(); + + $result = rest_validate_value_from_schema( array( 'orderby' => 'date' ), $schema ); + $this->assertInstanceOf( '\WP_Error', $result, 'orderby=date used to silently fall back to ID; the schema must now reject it.' ); + + $result = rest_validate_value_from_schema( array( 'orderby' => 'created' ), $schema ); + $this->assertTrue( $result, 'orderby=created must validate.' ); + } + + public function test_input_schema_rejects_in_arrays_over_max_items() { + $schema = $this->ability->get_input_schema(); + + $too_many = range( 1, 101 ); + $result = rest_validate_value_from_schema( array( 'user_id__in' => $too_many ), $schema ); + $this->assertInstanceOf( '\WP_Error', $result ); + + $at_limit = range( 1, 100 ); + $result = rest_validate_value_from_schema( array( 'user_id__in' => $at_limit ), $schema ); + $this->assertTrue( $result ); + } + + public function test_orderby_created_actually_orders_by_created_not_id() { + wp_set_current_user( $this->admin_user_id ); + + // Insert two records with explicit, out-of-order timestamps so that + // ordering by ID (the silent-fallback bug) and ordering by created + // produce different results. Record A has the higher ID but the older + // created timestamp; record B has the lower ID but the newer timestamp. + // With order=DESC + orderby=created, B must come first. + $older = '2020-01-01 00:00:00'; + $newer = '2024-12-31 23:59:59'; + + $id_a = $this->plugin->db->insert( + array( + 'site_id' => 1, + 'blog_id' => get_current_blog_id(), + 'user_id' => $this->admin_user_id, + 'created' => $older, + 'summary' => 'older record (higher ID)', + 'connector' => 'users', + 'context' => 'users', + 'action' => 'created', + 'ip' => '127.0.0.1', + ) + ); + $id_b = $this->plugin->db->insert( + array( + 'site_id' => 1, + 'blog_id' => get_current_blog_id(), + 'user_id' => $this->admin_user_id, + 'created' => $newer, + 'summary' => 'newer record (lower ID would not happen, but timestamp is newer)', + 'connector' => 'users', + 'context' => 'users', + 'action' => 'updated', + ) + ); + + $this->assertNotFalse( $id_a ); + $this->assertNotFalse( $id_b ); + $this->assertGreaterThan( $id_a, $id_b, 'Sanity: B was inserted after A.' ); + + $result = $this->ability->execute( + array( + 'orderby' => 'created', + 'order' => 'ASC', + 'records_per_page' => 50, + 'user_id__in' => array( $this->admin_user_id ), + ) + ); + + $created_seq = array_map( + static function ( $r ) { + return $r['created']; + }, + $result['records'] + ); + + // Find our two seeded records in the sequence and confirm older < newer. + $pos_older = array_search( $older, $created_seq, true ); + $pos_newer = array_search( $newer, $created_seq, true ); + $this->assertNotFalse( $pos_older, 'Seeded older record missing from result.' ); + $this->assertNotFalse( $pos_newer, 'Seeded newer record missing from result.' ); + $this->assertLessThan( $pos_newer, $pos_older, 'orderby=created ASC must place older before newer.' ); + } } From 929f4e75d2c8b5f2bae6f5b32bfd718f4503e1d5 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 18:39:10 +0530 Subject: [PATCH 13/24] create-alert: validate alert_type, set real post_title, split connector-context Mirror the admin form's create-alert flow (classes/class-alerts.php:766- 806) so API-created alerts behave identically to UI-created ones: - Validate alert_type against $plugin->alerts->alert_types (the registered notifier slugs). Schema can't enum these because wp_stream_alert_types is a filter -- a hardcoded enum would lock out 3rd-party notifiers. Reject unknown slugs with stream_unknown_alert_type / status 400 BEFORE inserting the post. - Split 'connector-context' input into trigger_connector + trigger_context meta keys, exactly like the admin form does. Without the split, Alert_Trigger_Context::check_record() silently let any connector through because trigger_connector was never populated -- alerts created via the API were effectively connector-agnostic. - Build an Alert model from the split meta and use $alert->get_title() for post_title, so the admin list shows a meaningful title instead of 'Auto Draft'. Tests cover the title regression, the connector-dash-context split, and the alert_type rejection path (including no-side-effects: no post is inserted when validation fails). --- abilities/class-ability-create-alert.php | 66 +++++++++++++++---- .../test-class-ability-create-alert.php | 66 ++++++++++++++++++- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/abilities/class-ability-create-alert.php b/abilities/class-ability-create-alert.php index 03aa79d83..e8e09af25 100644 --- a/abilities/class-ability-create-alert.php +++ b/abilities/class-ability-create-alert.php @@ -112,11 +112,65 @@ public function get_output_schema() { public function execute( $input = null ) { $status = isset( $input['status'] ) ? $input['status'] : 'wp_stream_enabled'; + // Validate alert_type against the registered notifier slugs. The schema + // can't enum these because alert types are extensible via the + // wp_stream_alert_types filter -- a hardcoded enum would lock out + // 3rd-party notifiers. Validate at execute() time instead. + $registered_types = isset( $this->plugin->alerts->alert_types ) + ? array_keys( (array) $this->plugin->alerts->alert_types ) + : array(); + if ( ! empty( $registered_types ) && ! in_array( $input['alert_type'], $registered_types, true ) ) { + return new \WP_Error( + 'stream_unknown_alert_type', + sprintf( + /* translators: 1: alert_type slug supplied by caller, 2: comma-separated list of registered alert type slugs */ + __( 'Unknown alert_type "%1$s". Registered types: %2$s.', 'stream' ), + (string) $input['alert_type'], + implode( ', ', $registered_types ) + ), + array( 'status' => 400 ) + ); + } + + // Mirror the admin form's connector-context split so Alert::get_title() + // and Alert_Trigger_Context::check_record() see the same data shape + // they see when an admin creates the alert through the UI. + $trigger_context_raw = (string) $input['trigger_context']; + if ( false !== strpos( $trigger_context_raw, '-' ) ) { + list( $trigger_connector, $trigger_context ) = explode( '-', $trigger_context_raw, 2 ); + } else { + $trigger_connector = $trigger_context_raw; + $trigger_context = ''; + } + + $extra_meta = isset( $input['alert_meta'] ) ? (array) $input['alert_meta'] : array(); + $alert_meta = array_merge( + $extra_meta, + array( + 'trigger_author' => $input['trigger_author'], + 'trigger_connector' => $trigger_connector, + 'trigger_context' => $trigger_context, + 'trigger_action' => $input['trigger_action'], + ) + ); + + // Build an Alert model so we can reuse Stream's title-generation logic + // (otherwise wp_insert_post stores 'Auto Draft' for empty post_title). + $alert_model = new Alert( + (object) array( + 'alert_type' => $input['alert_type'], + 'alert_meta' => $alert_meta, + 'status' => $status, + ), + $this->plugin + ); + $post_title = $alert_model->get_title(); + $post_id = wp_insert_post( array( 'post_type' => Alerts::POST_TYPE, 'post_status' => $status, - 'post_title' => '', + 'post_title' => $post_title, ), true ); @@ -125,16 +179,6 @@ public function execute( $input = null ) { return $post_id; } - $extra_meta = isset( $input['alert_meta'] ) ? (array) $input['alert_meta'] : array(); - $alert_meta = array_merge( - $extra_meta, - array( - 'trigger_author' => $input['trigger_author'], - 'trigger_context' => $input['trigger_context'], - 'trigger_action' => $input['trigger_action'], - ) - ); - update_post_meta( $post_id, 'alert_type', $input['alert_type'] ); update_post_meta( $post_id, 'alert_meta', $alert_meta ); diff --git a/tests/phpunit/abilities/test-class-ability-create-alert.php b/tests/phpunit/abilities/test-class-ability-create-alert.php index 15a322ff2..a13183c6d 100644 --- a/tests/phpunit/abilities/test-class-ability-create-alert.php +++ b/tests/phpunit/abilities/test-class-ability-create-alert.php @@ -56,6 +56,8 @@ public function test_permissions() { public function test_creates_alert_post_and_meta() { wp_set_current_user( $this->admin_user_id ); + // Connector-only trigger (no dash) -- stored with empty context, matching + // how the admin form handles "posts" vs "posts-post". $result = $this->ability->execute( array( 'alert_type' => 'highlight', @@ -73,7 +75,8 @@ public function test_creates_alert_post_and_meta() { $alert_meta = (array) get_post_meta( $result['id'], 'alert_meta', true ); $this->assertSame( 'any', $alert_meta['trigger_author'] ); - $this->assertSame( 'posts', $alert_meta['trigger_context'] ); + $this->assertSame( 'posts', $alert_meta['trigger_connector'] ); + $this->assertSame( '', $alert_meta['trigger_context'] ); $this->assertSame( 'updated', $alert_meta['trigger_action'] ); $this->assertSame( 'yellow', $alert_meta['color'] ); @@ -82,6 +85,67 @@ public function test_creates_alert_post_and_meta() { $this->assert_matches_schema( $result, $this->ability->get_output_schema() ); } + public function test_splits_connector_dash_context_input() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( + array( + 'alert_type' => 'highlight', + 'trigger_author' => 'any', + 'trigger_context' => 'posts-post', + 'trigger_action' => 'updated', + ) + ); + + $alert_meta = (array) get_post_meta( $result['id'], 'alert_meta', true ); + $this->assertSame( 'posts', $alert_meta['trigger_connector'] ); + $this->assertSame( 'post', $alert_meta['trigger_context'] ); + } + + public function test_post_title_is_not_auto_draft() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( + array( + 'alert_type' => 'highlight', + 'trigger_author' => 'any', + 'trigger_context' => 'posts', + 'trigger_action' => 'updated', + ) + ); + + $this->assertNotEmpty( $result['title'], 'Alert title must not be empty (would render as "Auto Draft" in admin).' ); + $this->assertNotSame( 'Auto Draft', $result['title'] ); + } + + public function test_rejects_unknown_alert_type() { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->ability->execute( + array( + 'alert_type' => 'sms', // Not a registered notifier. + 'trigger_author' => 'any', + 'trigger_context' => 'posts', + 'trigger_action' => 'updated', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'stream_unknown_alert_type', $result->get_error_code() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + + // No post should have been inserted before the validation error. + $alerts = get_posts( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => 'any', + 'numberposts' => -1, + 'fields' => 'ids', + ) + ); + $this->assertEmpty( $alerts ); + } + public function test_respects_disabled_status() { wp_set_current_user( $this->admin_user_id ); From 2709f9b9150068f4fbb2eb805e6c7a3cc94efc1c Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 18:44:09 +0530 Subject: [PATCH 14/24] Fix Abilities is_enabled() to honor network option on network-activated multisite On a network-activated multisite install, the Abilities API toggle is saved to the wp_stream_network site option via Network::update_site_option(). However, Settings::get_options() only reads from get_site_option() when is_network_admin() is true; in REST and frontend contexts $plugin->settings->options reflects the (typically empty) per-site option. As a result, is_enabled() returned false in REST even when the network admin had enabled the API, making the entire Abilities API silently unreachable on network-activated sites. Read the network option directly via get_site_option($settings->network_options_key) when is_multisite() && $plugin->is_network_activated(), and fall back to the existing in-memory per-site options otherwise (preserves single-site and per-site-activated behavior). Adds two regression tests: - test_is_enabled_reads_network_option_when_network_activated (@group ms-required) flips the wp_stream_is_network_activated filter and proves is_enabled() follows the network option even when in-memory options say disabled. - test_is_enabled_reads_per_site_options_when_not_network_activated proves the network option is ignored when the plugin isn't network-activated. --- classes/class-abilities.php | 25 ++++++- tests/phpunit/test-class-abilities.php | 92 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/classes/class-abilities.php b/classes/class-abilities.php index e8ff7204b..33dff4914 100644 --- a/classes/class-abilities.php +++ b/classes/class-abilities.php @@ -159,11 +159,32 @@ public function is_available() { /** * Whether the integration is enabled in Stream settings. * + * Reads the network-level option directly when Stream is network-activated, + * because Settings::get_options() only loads from get_site_option() inside + * is_network_admin() screens. In REST and frontend contexts on a + * network-activated install, $plugin->settings->options reflects the + * (typically empty) per-site option, which would silently keep the + * Abilities API disabled regardless of the network admin's setting. + * * @return bool */ public function is_enabled() { - $key = 'advanced_' . self::SETTING_NAME; - $options = isset( $this->plugin->settings ) ? (array) $this->plugin->settings->options : array(); + $key = 'advanced_' . self::SETTING_NAME; + + if ( + is_multisite() + && isset( $this->plugin->settings ) + && $this->plugin->is_network_activated() + ) { + $options = (array) get_site_option( + $this->plugin->settings->network_options_key, + array() + ); + } else { + $options = isset( $this->plugin->settings ) + ? (array) $this->plugin->settings->options + : array(); + } return ! empty( $options[ $key ] ); } diff --git a/tests/phpunit/test-class-abilities.php b/tests/phpunit/test-class-abilities.php index 6527cd894..d2efcbb69 100644 --- a/tests/phpunit/test-class-abilities.php +++ b/tests/phpunit/test-class-abilities.php @@ -52,6 +52,98 @@ public function test_is_enabled_reflects_settings_option() { $this->assertTrue( $abilities->is_enabled() ); } + /** + * On a network-activated multisite install, the toggle is stored in the + * network option (wp_stream_network) but Settings::get_options() only + * loads from get_site_option() inside is_network_admin(). REST and + * frontend contexts therefore see the (typically empty) per-site option + * via $plugin->settings->options. is_enabled() must read directly from + * the network option in that case. + * + * @group ms-required + */ + public function test_is_enabled_reads_network_option_when_network_activated() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Requires multisite.' ); + } + + $network_key = $this->plugin->settings->network_options_key; + + // Snapshot whatever's in the network option so we can restore. + $original_network = get_site_option( $network_key, false ); + + // Force network-activated state and ensure the in-memory per-site + // options do NOT have the toggle set, so a passing assertion proves + // is_enabled() consulted the network option (not the in-memory copy). + add_filter( 'wp_stream_is_network_activated', '__return_true' ); + $this->plugin->settings->options['advanced_enable_abilities_api'] = 0; + + try { + $abilities = new Abilities( $this->plugin ); + + update_site_option( + $network_key, + array( 'advanced_enable_abilities_api' => 0 ) + ); + $this->assertFalse( + $abilities->is_enabled(), + 'Network option disabled -> is_enabled() must be false even if in-memory options were ignored.' + ); + + update_site_option( + $network_key, + array( 'advanced_enable_abilities_api' => 1 ) + ); + $this->assertTrue( + $abilities->is_enabled(), + 'Network option enabled -> is_enabled() must be true even though in-memory options say disabled.' + ); + } finally { + remove_filter( 'wp_stream_is_network_activated', '__return_true' ); + if ( false === $original_network ) { + delete_site_option( $network_key ); + } else { + update_site_option( $network_key, $original_network ); + } + } + } + + /** + * Inverse of the above: on a non-network-activated install (which + * includes single-site and per-site activation on multisite), is_enabled() + * must continue to read the in-memory per-site options, not the network + * option. + */ + public function test_is_enabled_reads_per_site_options_when_not_network_activated() { + add_filter( 'wp_stream_is_network_activated', '__return_false' ); + + $network_key = $this->plugin->settings->network_options_key; + $original_network = get_site_option( $network_key, false ); + + try { + $abilities = new Abilities( $this->plugin ); + + // Network option set to enabled, but plugin is NOT network-activated: + // the network option must be ignored. + update_site_option( + $network_key, + array( 'advanced_enable_abilities_api' => 1 ) + ); + $this->plugin->settings->options['advanced_enable_abilities_api'] = 0; + $this->assertFalse( $abilities->is_enabled() ); + + $this->plugin->settings->options['advanced_enable_abilities_api'] = 1; + $this->assertTrue( $abilities->is_enabled() ); + } finally { + remove_filter( 'wp_stream_is_network_activated', '__return_false' ); + if ( false === $original_network ) { + delete_site_option( $network_key ); + } else { + update_site_option( $network_key, $original_network ); + } + } + } + public function test_constructor_does_not_hook_when_setting_disabled() { $this->plugin->settings->options['advanced_enable_abilities_api'] = 0; From 1c812987e4319580efb50c7634cf05bdff63b12e Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 18:56:40 +0530 Subject: [PATCH 15/24] Fix PHPCS errors in CR-fix tests Add //end try comments to satisfy Squiz.Commenting.LongConditionClosingComment on the new is_enabled() multisite tests, and lift the inline 'not a registered notifier' comment above the array literal so it doesn't trip Squiz.Commenting.PostStatementComment in the unknown-alert_type test. --- tests/phpunit/abilities/test-class-ability-create-alert.php | 3 ++- tests/phpunit/test-class-abilities.php | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/abilities/test-class-ability-create-alert.php b/tests/phpunit/abilities/test-class-ability-create-alert.php index a13183c6d..9cc4112a3 100644 --- a/tests/phpunit/abilities/test-class-ability-create-alert.php +++ b/tests/phpunit/abilities/test-class-ability-create-alert.php @@ -121,9 +121,10 @@ public function test_post_title_is_not_auto_draft() { public function test_rejects_unknown_alert_type() { wp_set_current_user( $this->admin_user_id ); + // 'sms' is not a registered notifier; the ability should reject before insert. $result = $this->ability->execute( array( - 'alert_type' => 'sms', // Not a registered notifier. + 'alert_type' => 'sms', 'trigger_author' => 'any', 'trigger_context' => 'posts', 'trigger_action' => 'updated', diff --git a/tests/phpunit/test-class-abilities.php b/tests/phpunit/test-class-abilities.php index d2efcbb69..84ebf899c 100644 --- a/tests/phpunit/test-class-abilities.php +++ b/tests/phpunit/test-class-abilities.php @@ -105,7 +105,7 @@ public function test_is_enabled_reads_network_option_when_network_activated() { } else { update_site_option( $network_key, $original_network ); } - } + }//end try } /** @@ -141,7 +141,7 @@ public function test_is_enabled_reads_per_site_options_when_not_network_activate } else { update_site_option( $network_key, $original_network ); } - } + }//end try } public function test_constructor_does_not_hook_when_setting_disabled() { From 6e4153f1b1179597a7bff142babe6ca0a481e306 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 4 May 2026 21:01:35 +0530 Subject: [PATCH 16/24] Correct misleading comment on IP validation in create-exclusion-rule The comment claimed format:ip is a hint not enforced by rest_validate_value_from_schema(), which is wrong. WP core's rest_is_ip_address() in wp-includes/rest-api.php DOES validate the format and rejects bogus IPs at the schema layer with ability_invalid_input before our execute() runs. Reframe the in-method check as defense-in-depth for direct PHP callers who invoke $ability->execute() outside the REST stack. --- abilities/class-ability-create-exclusion-rule.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/abilities/class-ability-create-exclusion-rule.php b/abilities/class-ability-create-exclusion-rule.php index d5efe2af3..d87101ea7 100644 --- a/abilities/class-ability-create-exclusion-rule.php +++ b/abilities/class-ability-create-exclusion-rule.php @@ -137,8 +137,11 @@ public function execute( $input = null ) { $sanitized[ $column ] = sanitize_text_field( $raw ); } - // JSON Schema's format:ip is a hint, not enforced by rest_validate_value_from_schema. - // Validate explicitly so bogus IPs never reach storage. + // WP REST enforces format:ip via rest_is_ip_address() before this method + // runs (see wp-includes/rest-api.php), so bogus IPs are normally + // rejected at the schema layer with ability_invalid_input. The check + // below is defense-in-depth for direct PHP callers who invoke + // $ability->execute() outside the REST stack and skip schema validation. if ( '' !== $sanitized['ip_address'] && ! filter_var( $sanitized['ip_address'], FILTER_VALIDATE_IP ) ) { return new \WP_Error( 'stream_invalid_ip', From 816ffc5e562967dbe577b48cc325153a056b0c41 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Wed, 6 May 2026 14:34:57 +0530 Subject: [PATCH 17/24] Fix PHPCS: add @param tags to execute/permission_callback, whitelist view_stream cap --- abilities/class-ability-create-alert.php | 2 ++ abilities/class-ability-create-exclusion-rule.php | 2 ++ abilities/class-ability-delete-alert.php | 2 ++ abilities/class-ability-get-alerts.php | 2 ++ abilities/class-ability-get-connectors.php | 2 ++ abilities/class-ability-get-exclusion-rules.php | 2 ++ abilities/class-ability-get-record.php | 2 ++ abilities/class-ability-get-records.php | 2 ++ abilities/class-ability-get-settings.php | 2 ++ abilities/class-ability-purge-records.php | 2 ++ abilities/class-ability-update-settings.php | 2 ++ abilities/trait-view-stream-permission.php | 2 ++ phpcs.xml.dist | 8 ++++++++ 13 files changed, 32 insertions(+) diff --git a/abilities/class-ability-create-alert.php b/abilities/class-ability-create-alert.php index e8e09af25..c39b03ed9 100644 --- a/abilities/class-ability-create-alert.php +++ b/abilities/class-ability-create-alert.php @@ -108,6 +108,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { $status = isset( $input['status'] ) ? $input['status'] : 'wp_stream_enabled'; diff --git a/abilities/class-ability-create-exclusion-rule.php b/abilities/class-ability-create-exclusion-rule.php index d87101ea7..9fd41ecf5 100644 --- a/abilities/class-ability-create-exclusion-rule.php +++ b/abilities/class-ability-create-exclusion-rule.php @@ -124,6 +124,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { $input = (array) $input; diff --git a/abilities/class-ability-delete-alert.php b/abilities/class-ability-delete-alert.php index 5f52912b6..b1978aff6 100644 --- a/abilities/class-ability-delete-alert.php +++ b/abilities/class-ability-delete-alert.php @@ -79,6 +79,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { $id = isset( $input['id'] ) ? (int) $input['id'] : 0; diff --git a/abilities/class-ability-get-alerts.php b/abilities/class-ability-get-alerts.php index c9667b18c..010f59e4a 100644 --- a/abilities/class-ability-get-alerts.php +++ b/abilities/class-ability-get-alerts.php @@ -95,6 +95,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { $requested = isset( $input['status'] ) ? $input['status'] : 'any'; diff --git a/abilities/class-ability-get-connectors.php b/abilities/class-ability-get-connectors.php index 22baef143..e75703596 100644 --- a/abilities/class-ability-get-connectors.php +++ b/abilities/class-ability-get-connectors.php @@ -91,6 +91,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { unset( $input ); diff --git a/abilities/class-ability-get-exclusion-rules.php b/abilities/class-ability-get-exclusion-rules.php index 8c77aba15..0a633c740 100644 --- a/abilities/class-ability-get-exclusion-rules.php +++ b/abilities/class-ability-get-exclusion-rules.php @@ -71,6 +71,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { unset( $input ); diff --git a/abilities/class-ability-get-record.php b/abilities/class-ability-get-record.php index bfbb32887..b543c7470 100644 --- a/abilities/class-ability-get-record.php +++ b/abilities/class-ability-get-record.php @@ -96,6 +96,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { global $wpdb; diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php index c450da7a8..430e86716 100644 --- a/abilities/class-ability-get-records.php +++ b/abilities/class-ability-get-records.php @@ -210,6 +210,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { $input = (array) $input; diff --git a/abilities/class-ability-get-settings.php b/abilities/class-ability-get-settings.php index 8955c33a9..3c8b76d4e 100644 --- a/abilities/class-ability-get-settings.php +++ b/abilities/class-ability-get-settings.php @@ -64,6 +64,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { unset( $input ); diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index ce91996b8..f89371ef1 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -101,6 +101,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { global $wpdb; diff --git a/abilities/class-ability-update-settings.php b/abilities/class-ability-update-settings.php index 4734b75a1..7f0f31a1c 100644 --- a/abilities/class-ability-update-settings.php +++ b/abilities/class-ability-update-settings.php @@ -76,6 +76,8 @@ public function get_output_schema() { /** * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. */ public function execute( $input = null ) { $option_key = $this->plugin->settings->option_key; diff --git a/abilities/trait-view-stream-permission.php b/abilities/trait-view-stream-permission.php index 840c0541b..baf5a7727 100644 --- a/abilities/trait-view-stream-permission.php +++ b/abilities/trait-view-stream-permission.php @@ -16,6 +16,8 @@ trait Trait_View_Stream_Permission { /** * {@inheritDoc} + * + * @param array $input Input that will be passed to execute(). */ public function permission_callback( $input = array() ) { unset( $input ); diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 48bb50632..c7c741d22 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -15,6 +15,14 @@ + + + + + + + + From b770d5c8e7bef362156a015187b9dad505a98a8a Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Wed, 6 May 2026 14:54:46 +0530 Subject: [PATCH 18/24] Address Copilot review: multisite purge scope, alert_meta shape, orderby test, UI text - purge-records: scope DELETE by blog_id on non-network-activated multisite to prevent cross-site record deletion; mirrors Admin::erase_stream_records(). - get-alerts: coerce missing alert_meta to {} instead of [""] so the response matches the declared object output schema. - test_orderby_created_actually_orders_by_created_not_id: invert insertion order so ID-order conflicts with created-order; the test would now fail if the implementation silently fell back to ORDER BY ID. - Settings UI: drop the specific /wp-abilities/v1/stream/* path from the toggle description (the actual route is owned by core's Abilities API). - Add regression tests for the missing alert_meta normalization and for the per-blog purge isolation (multisite-only). --- abilities/class-ability-get-alerts.php | 10 ++- abilities/class-ability-purge-records.php | 21 ++++- classes/class-settings.php | 2 +- .../test-class-ability-get-alerts.php | 31 ++++++++ .../test-class-ability-get-records.php | 44 ++++++----- .../test-class-ability-purge-records.php | 76 +++++++++++++++++++ 6 files changed, 162 insertions(+), 22 deletions(-) diff --git a/abilities/class-ability-get-alerts.php b/abilities/class-ability-get-alerts.php index 010f59e4a..0dfb81afb 100644 --- a/abilities/class-ability-get-alerts.php +++ b/abilities/class-ability-get-alerts.php @@ -122,12 +122,20 @@ public function execute( $input = null ) { $out = array(); foreach ( $posts as $post ) { + // get_post_meta() returns '' (string) when the key is missing; casting that + // to (array) yields a numerically-indexed array which JSON-encodes as a list + // and violates the declared object output schema. Only keep array values. + $alert_meta = get_post_meta( $post->ID, 'alert_meta', true ); + if ( ! is_array( $alert_meta ) ) { + $alert_meta = array(); + } + $out[] = array( 'id' => (int) $post->ID, 'status' => (string) $post->post_status, 'title' => (string) $post->post_title, 'alert_type' => get_post_meta( $post->ID, 'alert_type', true ), - 'alert_meta' => (array) get_post_meta( $post->ID, 'alert_meta', true ), + 'alert_meta' => $alert_meta, ); } diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index f89371ef1..d41aee3c6 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -115,28 +115,33 @@ public function execute( $input = null ) { ); } - $where = array( '1=1' ); - $params = array(); + $where = array( '1=1' ); + $params = array(); + $filter_count = 0; if ( ! empty( $input['older_than_days'] ) ) { $where[] = 'stream.created < DATE_SUB(NOW(), INTERVAL %d DAY)'; $params[] = (int) $input['older_than_days']; + ++$filter_count; } if ( ! empty( $input['connector'] ) ) { $where[] = 'stream.connector = %s'; $params[] = (string) $input['connector']; + ++$filter_count; } if ( ! empty( $input['context'] ) ) { $where[] = 'stream.context = %s'; $params[] = (string) $input['context']; + ++$filter_count; } if ( ! empty( $input['action'] ) ) { $where[] = 'stream.action = %s'; $params[] = (string) $input['action']; + ++$filter_count; } // Reject confirm-only payloads (no actual filter): refuse rather than truncate the table. - if ( count( $where ) === 1 ) { + if ( 0 === $filter_count ) { return new \WP_Error( 'stream_purge_no_filter', __( 'At least one filter (older_than_days, connector, context, action) must be supplied.', 'stream' ), @@ -144,6 +149,16 @@ public function execute( $input = null ) { ); } + // On non-network-activated multisite the stream tables are shared and + // records are separated by blog_id. Without this guard a site admin + // could purge records belonging to other sites. Mirrors + // Admin::erase_stream_records() / Admin::erase_large_records(). Added + // after the no-filter check so a confirm-only payload is still rejected. + if ( is_multisite() && $this->plugin->is_multisite_not_network_activated() ) { + $where[] = 'stream.blog_id = %d'; + $params[] = get_current_blog_id(); + } + $where_sql = implode( ' AND ', $where ); // Delete stream rows first and capture rows_affected so the response reflects diff --git a/classes/class-settings.php b/classes/class-settings.php index 422d694f4..253f48de3 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -406,7 +406,7 @@ public function get_fields() { 'name' => 'enable_abilities_api', 'title' => esc_html__( 'Enable Abilities API', 'stream' ), 'type' => 'checkbox', - 'desc' => esc_html__( 'Expose Stream operations via the WordPress Abilities API. Allows AI agents and automation tools to query records, manage alerts, and update settings through /wp-abilities/v1/stream/* REST endpoints. Requires WordPress 6.9 or newer.', 'stream' ), + 'desc' => esc_html__( 'Expose Stream operations via the WordPress Abilities API. Allows AI agents and automation tools to query records, manage alerts, and update settings through the Abilities API REST routes. Requires WordPress 6.9 or newer.', 'stream' ), 'after_field' => esc_html__( 'Enabled', 'stream' ), 'default' => 0, ); diff --git a/tests/phpunit/abilities/test-class-ability-get-alerts.php b/tests/phpunit/abilities/test-class-ability-get-alerts.php index 8ada6886b..c7af880fe 100644 --- a/tests/phpunit/abilities/test-class-ability-get-alerts.php +++ b/tests/phpunit/abilities/test-class-ability-get-alerts.php @@ -88,4 +88,35 @@ public function test_returns_seeded_alerts() { $this->assertSame( 'highlight', $enabled[0]['alert_type'] ); $this->assertSame( $disabled_id, $disabled[0]['id'] ); } + + public function test_alert_meta_is_normalized_to_object_when_missing() { + wp_set_current_user( $this->admin_user_id ); + + // Alert with no alert_meta post meta at all. get_post_meta() returns '' + // in that case; the ability must coerce that to {} rather than [""], or + // the response will violate the declared object output schema. + $post_id = wp_insert_post( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => 'wp_stream_enabled', + 'post_title' => 'Alert without meta', + ) + ); + + $result = $this->ability->execute( array( 'status' => 'any' ) ); + $row = null; + foreach ( $result as $entry ) { + if ( $entry['id'] === $post_id ) { + $row = $entry; + break; + } + } + + $this->assertNotNull( $row, 'Seeded alert missing from get-alerts output.' ); + $this->assertIsArray( $row['alert_meta'] ); + $this->assertSame( array(), $row['alert_meta'], 'Missing alert_meta must serialize as an empty object, not ["" ].' ); + + // Schema validates as well — exercises the live contract. + $this->assert_matches_schema( $result, $this->ability->get_output_schema() ); + } } diff --git a/tests/phpunit/abilities/test-class-ability-get-records.php b/tests/phpunit/abilities/test-class-ability-get-records.php index e8cf0b1b4..f84c33185 100644 --- a/tests/phpunit/abilities/test-class-ability-get-records.php +++ b/tests/phpunit/abilities/test-class-ability-get-records.php @@ -159,43 +159,48 @@ public function test_input_schema_rejects_in_arrays_over_max_items() { public function test_orderby_created_actually_orders_by_created_not_id() { wp_set_current_user( $this->admin_user_id ); - // Insert two records with explicit, out-of-order timestamps so that - // ordering by ID (the silent-fallback bug) and ordering by created - // produce different results. Record A has the higher ID but the older - // created timestamp; record B has the lower ID but the newer timestamp. - // With order=DESC + orderby=created, B must come first. - $older = '2020-01-01 00:00:00'; + // Make ID-order conflict with created-order so that an implementation + // silently falling back to ORDER BY ID would fail this test. We insert + // the *newer*-timestamp record FIRST (so it gets the lower ID) and the + // *older*-timestamp record SECOND (higher ID). Asking for orderby=created + // ASC must then place the higher-ID row before the lower-ID row, which + // is the opposite of ID ordering. $newer = '2024-12-31 23:59:59'; + $older = '2020-01-01 00:00:00'; - $id_a = $this->plugin->db->insert( + $id_newer_first = $this->plugin->db->insert( array( 'site_id' => 1, 'blog_id' => get_current_blog_id(), 'user_id' => $this->admin_user_id, - 'created' => $older, - 'summary' => 'older record (higher ID)', + 'created' => $newer, + 'summary' => 'newer timestamp, lower ID', 'connector' => 'users', 'context' => 'users', 'action' => 'created', 'ip' => '127.0.0.1', ) ); - $id_b = $this->plugin->db->insert( + $id_older_second = $this->plugin->db->insert( array( 'site_id' => 1, 'blog_id' => get_current_blog_id(), 'user_id' => $this->admin_user_id, - 'created' => $newer, - 'summary' => 'newer record (lower ID would not happen, but timestamp is newer)', + 'created' => $older, + 'summary' => 'older timestamp, higher ID', 'connector' => 'users', 'context' => 'users', 'action' => 'updated', ) ); - $this->assertNotFalse( $id_a ); - $this->assertNotFalse( $id_b ); - $this->assertGreaterThan( $id_a, $id_b, 'Sanity: B was inserted after A.' ); + $this->assertNotFalse( $id_newer_first ); + $this->assertNotFalse( $id_older_second ); + $this->assertGreaterThan( + $id_newer_first, + $id_older_second, + 'Sanity: the older-timestamp row must have the higher ID so ID-order conflicts with created-order.' + ); $result = $this->ability->execute( array( @@ -213,11 +218,16 @@ static function ( $r ) { $result['records'] ); - // Find our two seeded records in the sequence and confirm older < newer. $pos_older = array_search( $older, $created_seq, true ); $pos_newer = array_search( $newer, $created_seq, true ); $this->assertNotFalse( $pos_older, 'Seeded older record missing from result.' ); $this->assertNotFalse( $pos_newer, 'Seeded newer record missing from result.' ); - $this->assertLessThan( $pos_newer, $pos_older, 'orderby=created ASC must place older before newer.' ); + // The older-timestamp row was inserted SECOND (higher ID); ID-ASC would put it + // after the newer-timestamp row. orderby=created ASC must put it BEFORE. + $this->assertLessThan( + $pos_newer, + $pos_older, + 'orderby=created ASC must place the older-timestamp record before the newer one, even though it has the higher ID.' + ); } } diff --git a/tests/phpunit/abilities/test-class-ability-purge-records.php b/tests/phpunit/abilities/test-class-ability-purge-records.php index 2f4086bd9..d974c0422 100644 --- a/tests/phpunit/abilities/test-class-ability-purge-records.php +++ b/tests/phpunit/abilities/test-class-ability-purge-records.php @@ -138,4 +138,80 @@ public function test_zero_match_returns_zero_count() { $this->assertSame( array( 'deleted' => 0 ), $result ); } + + public function test_purge_does_not_cross_blog_boundary_when_not_network_activated() { + global $wpdb; + + if ( ! is_multisite() ) { + $this->markTestSkipped( 'This test requires multisite.' ); + } + if ( $this->plugin->is_network_activated() ) { + $this->markTestSkipped( 'This regression only applies when Stream is per-site activated on multisite.' ); + } + + wp_set_current_user( $this->admin_user_id ); + + // Seed a record under a foreign blog id directly via the table so the + // purge running on the current blog must not touch it. We bypass the + // log API to control blog_id explicitly. + $current_blog_id = (int) get_current_blog_id(); + $foreign_blog_id = $current_blog_id + 999; + + $inserted_foreign = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->stream, + array( + 'site_id' => 1, + 'blog_id' => $foreign_blog_id, + 'user_id' => $this->admin_user_id, + 'created' => '2020-01-01 00:00:00', + 'summary' => 'Foreign-blog record that must survive the purge.', + 'connector' => 'users', + 'context' => 'users', + 'action' => 'created', + 'ip' => '127.0.0.1', + ) + ); + $this->assertSame( 1, $inserted_foreign ); + + // Seed a current-blog record matching the same filter. + $this->plugin->log->log( + 'users', + 'Current-blog record (target)', + array(), + 0, + 'users', + 'created' + ); + + $result = $this->ability->execute( + array( + 'confirm' => true, + 'connector' => 'users', + ) + ); + + $this->assertIsArray( $result ); + + // Foreign-blog row must remain. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $foreign_left = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->stream} WHERE blog_id = %d AND connector = %s", + $foreign_blog_id, + 'users' + ) + ); + $this->assertSame( 1, $foreign_left, 'Purge must not delete records belonging to other blogs.' ); + + // Current-blog rows are gone. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $current_left = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->stream} WHERE blog_id = %d AND connector = %s", + $current_blog_id, + 'users' + ) + ); + $this->assertSame( 0, $current_left ); + } } From e6f625d1fa7dd9e0ce07fbe3ebe1cbe02e917b02 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Wed, 6 May 2026 15:15:26 +0530 Subject: [PATCH 19/24] Address Copilot follow-up: REST scoping on network-activated multisite, UTC cutoff, settings UX, hook isolation - get-record / purge-records: scope by blog_id = get_current_blog_id() on any multisite request that is not is_network_admin() (REST is never network-admin). Replaces the previous is_multisite_not_network_activated() predicate, which left network-activated installs unprotected against a per-site admin reading or purging another site's records via REST. Mirrors Network::network_query_args() default scoping. - purge-records: compute the older_than_days cutoff as a UTC DateTime in PHP and bind as %s, mirroring Admin::purge_scheduled_action(). The previous DATE_SUB(NOW(), INTERVAL %d DAY) used the MySQL server timezone while Stream's created column is UTC, so it could over- or under-purge on hosts where the server timezone is not UTC. - Settings: hide the "Enable Abilities API" toggle on per-site settings screens when Stream is network-activated. The setting is read from the network option in that mode, so a per-site checkbox would have been a silent no-op. - Tests: snapshot and restore $wp_filter['wp_abilities_api_init'] in Test_Abilities::setUp/tearDown so the existing remove_all_actions() calls inside individual tests don't pollute the global hook registry for subsequent tests in the same process. - Tests: lock the new behaviors with regression coverage: * get-record returns stream_record_not_found for foreign-blog IDs on multisite (must not leak via guessing). * purge-records does not cross blog boundaries on multisite (rename drops the _when_not_network_activated suffix). * purge-records older_than_days uses a UTC cutoff: rows seeded with explicit UTC timestamps purge correctly regardless of server tz. * Settings field is visible on non-network-activated installs. --- abilities/class-ability-get-record.php | 14 ++-- abilities/class-ability-purge-records.php | 26 +++++--- classes/class-settings.php | 13 +++- .../test-class-ability-get-record.php | 38 +++++++++++ .../test-class-ability-purge-records.php | 64 +++++++++++++++++-- tests/phpunit/test-class-abilities.php | 42 ++++++++++++ 6 files changed, 176 insertions(+), 21 deletions(-) diff --git a/abilities/class-ability-get-record.php b/abilities/class-ability-get-record.php index b543c7470..aed5c9bf7 100644 --- a/abilities/class-ability-get-record.php +++ b/abilities/class-ability-get-record.php @@ -106,13 +106,17 @@ public function execute( $input = null ) { // Stream's Query class doesn't expose a single-ID filter (record__in // is broken for one-element arrays due to array_shift() in - // Query::query()), so query the table directly. We add an explicit - // blog_id filter on multisite so the response can never leak a record - // from another site on a network install — the admin records page is - // scoped per-site and abilities must match. + // Query::query()), so query the table directly. On any multisite + // install, scope reads to the current blog unless the request is + // running inside Network Admin — this mirrors Network::network_query_args() + // (default blog_id is get_current_blog_id() outside network admin) and + // applies the same protection in REST contexts, where is_network_admin() + // is always false. Without this guard, a user with view_stream on one + // site of a network-activated install could fetch records from other + // sites by guessing IDs. $where = ''; $prepared = array( $id ); - if ( is_multisite() && ! $this->plugin->is_network_activated() ) { + if ( is_multisite() && ! is_network_admin() ) { $where = ' AND blog_id = %d'; $prepared[] = get_current_blog_id(); } diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index d41aee3c6..83e4b299a 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -120,8 +120,17 @@ public function execute( $input = null ) { $filter_count = 0; if ( ! empty( $input['older_than_days'] ) ) { - $where[] = 'stream.created < DATE_SUB(NOW(), INTERVAL %d DAY)'; - $params[] = (int) $input['older_than_days']; + // Stream stores `created` in UTC (Log::log() writes current_time('mysql', true)). + // MySQL's NOW() uses the server timezone, so comparing against it can + // delete too many or too few rows on hosts where the server is not UTC. + // Mirror Admin::purge_scheduled_action(): compute a UTC cutoff in PHP + // and bind it as a string. + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( ( (int) $input['older_than_days'] ) . ' days' ) ) + ->format( 'Y-m-d H:i:s' ); + + $where[] = 'stream.created < %s'; + $params[] = $cutoff; ++$filter_count; } if ( ! empty( $input['connector'] ) ) { @@ -149,12 +158,13 @@ public function execute( $input = null ) { ); } - // On non-network-activated multisite the stream tables are shared and - // records are separated by blog_id. Without this guard a site admin - // could purge records belonging to other sites. Mirrors - // Admin::erase_stream_records() / Admin::erase_large_records(). Added - // after the no-filter check so a confirm-only payload is still rejected. - if ( is_multisite() && $this->plugin->is_multisite_not_network_activated() ) { + // On any multisite install, scope the purge to the current blog whenever + // the request is not running inside Network Admin. REST is never + // is_network_admin(), so this also protects network-activated installs + // from a site admin (with the settings cap) wiping another site's records + // via the REST endpoint. Mirrors Network::network_query_args() defaults. + // Added after the no-filter check so a confirm-only payload is still rejected. + if ( is_multisite() && ! is_network_admin() ) { $where[] = 'stream.blog_id = %d'; $params[] = get_current_blog_id(); } diff --git a/classes/class-settings.php b/classes/class-settings.php index 253f48de3..c8a50aa06 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -400,8 +400,17 @@ public function get_fields() { array_push( $fields['advanced']['fields'], $wp_cron_tracking ); - // Abilities API toggle is only meaningful on WordPress 6.9+. - if ( class_exists( '\WP_Ability' ) ) { + // Abilities API toggle is only meaningful on WordPress 6.9+. On + // network-activated multisite, Abilities::is_enabled() reads the + // network option (wp_stream_network), so a per-site checkbox on the + // site's own settings screen would be a no-op and misleading. Only + // expose the field when it actually drives behavior: in network admin + // (where settings save to the network option), or on installs where + // the per-site option is authoritative. + if ( + class_exists( '\WP_Ability' ) + && ( ! $this->plugin->is_network_activated() || is_network_admin() ) + ) { $enable_abilities_api = array( 'name' => 'enable_abilities_api', 'title' => esc_html__( 'Enable Abilities API', 'stream' ), diff --git a/tests/phpunit/abilities/test-class-ability-get-record.php b/tests/phpunit/abilities/test-class-ability-get-record.php index aad1c71a6..9e1dbe9a2 100644 --- a/tests/phpunit/abilities/test-class-ability-get-record.php +++ b/tests/phpunit/abilities/test-class-ability-get-record.php @@ -79,4 +79,42 @@ public function test_returns_record_with_meta_when_found() { $this->assertArrayHasKey( 'meta', $result ); $this->assertIsArray( $result['meta'] ); } + + public function test_does_not_leak_records_from_other_blogs_on_multisite() { + global $wpdb; + + if ( ! is_multisite() ) { + $this->markTestSkipped( 'This test requires multisite.' ); + } + + wp_set_current_user( $this->admin_user_id ); + + // Seed a record under a foreign blog id directly. Whether Stream is + // network-activated or not, a REST request from the current blog must + // not be able to fetch it (REST is never is_network_admin()). + $current_blog_id = (int) get_current_blog_id(); + $foreign_blog_id = $current_blog_id + 4242; + + $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->stream, + array( + 'site_id' => 1, + 'blog_id' => $foreign_blog_id, + 'user_id' => $this->admin_user_id, + 'created' => '2020-01-01 00:00:00', + 'summary' => 'Foreign-blog record that must not be readable.', + 'connector' => 'users', + 'context' => 'users', + 'action' => 'created', + 'ip' => '127.0.0.1', + ) + ); + $this->assertSame( 1, $inserted ); + $foreign_id = (int) $wpdb->insert_id; + + $result = $this->ability->execute( array( 'id' => $foreign_id ) ); + + $this->assertInstanceOf( '\WP_Error', $result, 'get-record must not return another blog\'s record on multisite.' ); + $this->assertSame( 'stream_record_not_found', $result->get_error_code() ); + } } diff --git a/tests/phpunit/abilities/test-class-ability-purge-records.php b/tests/phpunit/abilities/test-class-ability-purge-records.php index d974c0422..8a466c142 100644 --- a/tests/phpunit/abilities/test-class-ability-purge-records.php +++ b/tests/phpunit/abilities/test-class-ability-purge-records.php @@ -139,21 +139,73 @@ public function test_zero_match_returns_zero_count() { $this->assertSame( array( 'deleted' => 0 ), $result ); } - public function test_purge_does_not_cross_blog_boundary_when_not_network_activated() { + public function test_older_than_days_uses_utc_cutoff_not_server_now() { + global $wpdb; + + wp_set_current_user( $this->admin_user_id ); + + // Seed two rows with explicit UTC `created` values: one safely older than + // the cutoff (must be deleted), one safely newer (must be preserved). + // The values are chosen so any sane server timezone (UTC, UTC-12..UTC+14) + // still sorts them on the correct side of "now - 5 days" UTC. + $utc_now = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ); + $older = clone $utc_now; + $newer = clone $utc_now; + $older_utc = $older->modify( '-30 days' )->format( 'Y-m-d H:i:s' ); + $newer_utc = $newer->modify( '-1 hour' )->format( 'Y-m-d H:i:s' ); + + $current_blog_id = is_multisite() ? (int) get_current_blog_id() : 0; + + foreach ( array( $older_utc, $newer_utc ) as $created ) { + $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->stream, + array( + 'site_id' => 1, + 'blog_id' => $current_blog_id, + 'user_id' => $this->admin_user_id, + 'created' => $created, + 'summary' => 'utc-cutoff fixture (' . $created . ')', + 'connector' => 'tz_test', + 'context' => 'tz_test', + 'action' => 'created', + 'ip' => '127.0.0.1', + ) + ); + $this->assertSame( 1, $inserted ); + } + + $result = $this->ability->execute( + array( + 'confirm' => true, + 'connector' => 'tz_test', + 'older_than_days' => 5, + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( 1, $result['deleted'], 'Exactly the older row must be purged.' ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $remaining = $wpdb->get_col( + $wpdb->prepare( "SELECT created FROM {$wpdb->stream} WHERE connector = %s ORDER BY created", 'tz_test' ) + ); + $this->assertSame( array( $newer_utc ), $remaining ); + } + + public function test_purge_does_not_cross_blog_boundary_on_multisite() { global $wpdb; if ( ! is_multisite() ) { $this->markTestSkipped( 'This test requires multisite.' ); } - if ( $this->plugin->is_network_activated() ) { - $this->markTestSkipped( 'This regression only applies when Stream is per-site activated on multisite.' ); - } wp_set_current_user( $this->admin_user_id ); // Seed a record under a foreign blog id directly via the table so the - // purge running on the current blog must not touch it. We bypass the - // log API to control blog_id explicitly. + // purge running on the current blog must not touch it. The scoping + // applies on any multisite request that is not is_network_admin() — + // REST endpoints are never network-admin, so this is the relevant + // guard regardless of activation mode. $current_blog_id = (int) get_current_blog_id(); $foreign_blog_id = $current_blog_id + 999; diff --git a/tests/phpunit/test-class-abilities.php b/tests/phpunit/test-class-abilities.php index 84ebf899c..5aff16c74 100644 --- a/tests/phpunit/test-class-abilities.php +++ b/tests/phpunit/test-class-abilities.php @@ -19,6 +19,15 @@ class Test_Abilities extends WP_StreamTestCase { */ private $original_options; + /** + * Snapshot of the wp_abilities_api_init filter to restore in tearDown(). + * Saved as a WP_Hook clone so all priority/callback bindings survive the + * intentional remove_all_actions() calls inside individual tests. + * + * @var \WP_Hook|null + */ + private $original_abilities_init_hook; + /** * {@inheritDoc} */ @@ -27,6 +36,11 @@ public function setUp(): void { $this->original_options = isset( $this->plugin->settings->options ) ? (array) $this->plugin->settings->options : array(); + + global $wp_filter; + $this->original_abilities_init_hook = isset( $wp_filter['wp_abilities_api_init'] ) + ? clone $wp_filter['wp_abilities_api_init'] + : null; } /** @@ -34,6 +48,16 @@ public function setUp(): void { */ public function tearDown(): void { $this->plugin->settings->options = $this->original_options; + + // Restore the wp_abilities_api_init hook registry so tests that mutate + // it (via remove_all_actions) don't bleed into subsequent tests. + global $wp_filter; + if ( null === $this->original_abilities_init_hook ) { + unset( $wp_filter['wp_abilities_api_init'] ); + } else { + $wp_filter['wp_abilities_api_init'] = $this->original_abilities_init_hook; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } + parent::tearDown(); } @@ -210,6 +234,24 @@ public function test_load_abilities_populates_all_slugs() { } } + public function test_settings_field_visible_when_not_network_activated() { + if ( ! class_exists( '\WP_Ability' ) ) { + $this->markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); + } + if ( $this->plugin->is_network_activated() ) { + $this->markTestSkipped( 'Test asserts the non-network-activated branch.' ); + } + + $fields = $this->plugin->settings->get_fields(); + $advanced_field_names = wp_list_pluck( $fields['advanced']['fields'], 'name' ); + + $this->assertContains( + 'enable_abilities_api', + $advanced_field_names, + 'Toggle must be visible on per-site settings when Stream is not network-activated.' + ); + } + public function test_register_abilities_loads_and_registers_when_action_fires() { if ( ! class_exists( '\WP_Ability' ) ) { $this->markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); From f21ea46daed3c2c50d2a67d3e279413cd667ba25 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Thu, 7 May 2026 12:47:53 +0530 Subject: [PATCH 20/24] Address Copilot follow-up: combine stream + meta deletion into one statement Replace the post-DELETE orphan sweep over the entire streammeta table with a single multi-table DELETE that removes matching stream rows and their meta in one statement, mirroring Admin::purge_scheduled_action(). Capture the parent count up-front so the response still reports records-deleted independent of how many meta rows were attached. Also drop a stale comment that referenced a guard pattern no longer in the code, and update the output-schema description so it matches the implementation (no more 'cascade' wording, since there is no FK). --- abilities/class-ability-purge-records.php | 39 +++++++++++------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index 83e4b299a..526911269 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -92,7 +92,7 @@ public function get_output_schema() { 'properties' => array( 'deleted' => array( 'type' => 'integer', - 'description' => 'Number of stream records deleted (meta rows are cascaded by record_id).', + 'description' => 'Number of stream records deleted. Associated meta rows are removed in the same multi-table DELETE.', 'minimum' => 0, ), ), @@ -171,30 +171,29 @@ public function execute( $input = null ) { $where_sql = implode( ' AND ', $where ); - // Delete stream rows first and capture rows_affected so the response reflects - // the actual count (rather than a stale pre-DELETE COUNT). $params is guaranteed - // non-empty here by the count( $where ) === 1 guard above. MySQL requires the - // "DELETE alias FROM tbl AS alias" form when the WHERE references an alias. - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery - $delete_sql = "DELETE stream FROM {$wpdb->stream} AS stream WHERE {$where_sql}"; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( $wpdb->prepare( $delete_sql, $params ) ); - $deleted = (int) $wpdb->rows_affected; + // Count matching parent rows up-front so the response reports the number + // of *records* deleted, independent of how many meta rows were attached. + // $params is guaranteed non-empty here by the $filter_count > 0 guard above. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $count_sql = "SELECT COUNT(*) FROM {$wpdb->stream} AS stream WHERE {$where_sql}"; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + $deleted = (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $params ) ); if ( 0 === $deleted ) { return array( 'deleted' => 0 ); } - // Sweep orphaned meta rows whose parent record was just deleted. Idempotent - // and safe to run unconditionally; the LEFT JOIN scopes the cleanup to - // orphans across the whole streammeta table, which also catches any prior - // orphans without growing this query's blast radius beyond a single sweep. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( - "DELETE meta FROM {$wpdb->streammeta} AS meta - LEFT JOIN {$wpdb->stream} AS stream ON stream.ID = meta.record_id - WHERE stream.ID IS NULL" - ); + // Delete matching stream rows AND their meta in a single multi-table DELETE, + // mirroring Admin::purge_scheduled_action(). Doing both sides in one statement + // avoids a follow-up full-table scan over $wpdb->streammeta to clean up + // orphans, which on busy sites could lock the meta table for a long time. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery + $delete_sql = "DELETE stream, meta + FROM {$wpdb->stream} AS stream + LEFT JOIN {$wpdb->streammeta} AS meta ON meta.record_id = stream.ID + WHERE {$where_sql}"; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( $wpdb->prepare( $delete_sql, $params ) ); return array( 'deleted' => $deleted ); } From a0e4c4251ba37c80bc2fa0e882c3f0fede393d44 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Thu, 7 May 2026 13:04:16 +0530 Subject: [PATCH 21/24] Address Copilot follow-up: refresh settings via get_options() and tighten schema descriptions After update_option() the raw option array is sparse (omits defaults). Both update-settings and create-exclusion-rule were assigning that sparse array directly to $plugin->settings->options, leaving default-only keys missing for any later code in the same request. Refresh the in-memory copy via Settings::get_options() so defaults are merged in. Also align two schema descriptions with actual behavior: - update-settings now says unknown keys are *ignored* (the request only fails when no key matches a registered setting). This matches the array_intersect_key() filtering in execute(). - get-settings no longer promises advanced_enable_abilities_api in every response; that field is only registered on WP 6.9+ and, on network-activated multisite, only from network admin. --- abilities/class-ability-create-exclusion-rule.php | 6 ++++-- abilities/class-ability-get-settings.php | 2 +- abilities/class-ability-update-settings.php | 11 +++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/abilities/class-ability-create-exclusion-rule.php b/abilities/class-ability-create-exclusion-rule.php index 9fd41ecf5..222d6fed4 100644 --- a/abilities/class-ability-create-exclusion-rule.php +++ b/abilities/class-ability-create-exclusion-rule.php @@ -215,8 +215,10 @@ public function execute( $input = null ) { $options['exclude_rules'] = $rules; update_option( $option_key, $options ); - // Refresh in-memory copy. - $this->plugin->settings->options = $options; + // Refresh in-memory copy through get_options() so defaults are merged + // in (the raw option is sparse). Direct assignment of $options would + // leave default-only keys missing for the rest of the request. + $this->plugin->settings->options = $this->plugin->settings->get_options(); return array( 'index' => $index, diff --git a/abilities/class-ability-get-settings.php b/abilities/class-ability-get-settings.php index 3c8b76d4e..a01b03738 100644 --- a/abilities/class-ability-get-settings.php +++ b/abilities/class-ability-get-settings.php @@ -58,7 +58,7 @@ public function get_output_schema() { return array( 'type' => 'object', 'additionalProperties' => true, - 'description' => 'Settings keyed by {section}_{field} (e.g. general_records_ttl, advanced_enable_abilities_api).', + 'description' => 'Settings keyed by {section}_{field} (e.g. general_records_ttl). Available keys depend on which settings fields are registered for the current context: e.g. advanced_enable_abilities_api is only present on WordPress 6.9+ and, on network-activated multisite, only when read from network admin.', ); } diff --git a/abilities/class-ability-update-settings.php b/abilities/class-ability-update-settings.php index 7f0f31a1c..8dbf52123 100644 --- a/abilities/class-ability-update-settings.php +++ b/abilities/class-ability-update-settings.php @@ -55,7 +55,7 @@ public function get_input_schema() { 'properties' => array( 'settings' => array( 'type' => 'object', - 'description' => 'Partial settings map keyed by {section}_{field} (e.g. general_records_ttl). Unknown keys are rejected; values are normalized through Stream\'s settings sanitizer. Omitted keys are preserved.', + 'description' => 'Partial settings map keyed by {section}_{field} (e.g. general_records_ttl). Unknown keys are ignored (the request fails only when no key matches a registered setting); recognized values are normalized through Stream\'s settings sanitizer. Omitted keys are preserved.', 'additionalProperties' => true, 'minProperties' => 1, ), @@ -115,9 +115,12 @@ public function execute( $input = null ) { update_option( $option_key, $merged ); - // Refresh in-memory copy so subsequent abilities see the change. - $this->plugin->settings->options = $merged; + // Refresh in-memory copy through get_options() so defaults are merged + // in (the raw option is sparse). Subsequent abilities and any code + // that reads $plugin->settings->options in the same request need the + // fully-populated array to avoid undefined-index notices. + $this->plugin->settings->options = $this->plugin->settings->get_options(); - return $merged; + return $this->plugin->settings->options; } } From 5117ef1f18e39353da7cdcf857eea2829970572f Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Thu, 7 May 2026 13:39:42 +0530 Subject: [PATCH 22/24] Fix alert_meta JSON shape for empty meta in stream/get-alerts When a wp_stream_alerts post has no alert_meta postmeta row, the ability was emitting an empty PHP array(), which JSON-encodes as [] and violates the declared 'type: object' output schema. Replace the empty array with a stdClass instance so wp_json_encode() emits {} as the output schema requires. Also strengthen the regression test to assert against the JSON-encoded payload (the previous PHP-level array() comparison was passing despite the wire-format bug). --- abilities/class-ability-get-alerts.php | 11 ++++++----- .../abilities/test-class-ability-get-alerts.php | 11 +++++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/abilities/class-ability-get-alerts.php b/abilities/class-ability-get-alerts.php index 0dfb81afb..0cfcfc94c 100644 --- a/abilities/class-ability-get-alerts.php +++ b/abilities/class-ability-get-alerts.php @@ -122,12 +122,13 @@ public function execute( $input = null ) { $out = array(); foreach ( $posts as $post ) { - // get_post_meta() returns '' (string) when the key is missing; casting that - // to (array) yields a numerically-indexed array which JSON-encodes as a list - // and violates the declared object output schema. Only keep array values. + // get_post_meta() returns '' (string) when the key is missing; an + // empty PHP array() also JSON-encodes as a list ([]), which violates + // the declared object output schema. Normalize both cases to a real + // object so wp_json_encode() emits {} when there is no meta. $alert_meta = get_post_meta( $post->ID, 'alert_meta', true ); - if ( ! is_array( $alert_meta ) ) { - $alert_meta = array(); + if ( ! is_array( $alert_meta ) || array() === $alert_meta ) { + $alert_meta = new \stdClass(); } $out[] = array( diff --git a/tests/phpunit/abilities/test-class-ability-get-alerts.php b/tests/phpunit/abilities/test-class-ability-get-alerts.php index c7af880fe..b020a524a 100644 --- a/tests/phpunit/abilities/test-class-ability-get-alerts.php +++ b/tests/phpunit/abilities/test-class-ability-get-alerts.php @@ -113,8 +113,15 @@ public function test_alert_meta_is_normalized_to_object_when_missing() { } $this->assertNotNull( $row, 'Seeded alert missing from get-alerts output.' ); - $this->assertIsArray( $row['alert_meta'] ); - $this->assertSame( array(), $row['alert_meta'], 'Missing alert_meta must serialize as an empty object, not ["" ].' ); + + // Must be a real object so wp_json_encode() emits {}. An empty PHP + // array() would JSON-encode as [] and violate the declared object + // output schema. + $this->assertInstanceOf( \stdClass::class, $row['alert_meta'] ); + + $encoded = wp_json_encode( $row ); + $this->assertNotFalse( $encoded ); + $this->assertStringContainsString( '"alert_meta":{}', $encoded, 'Missing alert_meta must serialize as {}, not [].' ); // Schema validates as well — exercises the live contract. $this->assert_matches_schema( $result, $this->ability->get_output_schema() ); From ac3c1c1e32fd10e3e8eda5cec602d2f3958f0055 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Thu, 7 May 2026 13:46:04 +0530 Subject: [PATCH 23/24] Add docblocks to REST integration test methods to satisfy PHPCS Squiz.Commenting.FunctionComment.WrongStyle was flagging the three test methods that had only inline section comments above them, and Generic.Commenting.DocComment.MissingShort was flagging the @expectedIncorrectUsage-only docblock on test_unknown_ability_returns_404. --- tests/phpunit/abilities/test-rest-integration.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/phpunit/abilities/test-rest-integration.php b/tests/phpunit/abilities/test-rest-integration.php index 8b121a494..037d11a75 100644 --- a/tests/phpunit/abilities/test-rest-integration.php +++ b/tests/phpunit/abilities/test-rest-integration.php @@ -91,6 +91,9 @@ private function run_url( $slug ) { // Read-only ability: stream/get-records (GET). // ------------------------------------------------------------------- + /** + * GET on a readonly ability dispatches successfully for an admin. + */ public function test_get_records_returns_200_for_admin() { wp_set_current_user( $this->admin_user_id ); @@ -130,6 +133,9 @@ public function test_get_records_rejects_post_method() { // Write ability: stream/create-alert (POST). // ------------------------------------------------------------------- + /** + * POST on a write ability dispatches successfully for an admin. + */ public function test_create_alert_returns_200_for_admin() { wp_set_current_user( $this->admin_user_id ); @@ -193,6 +199,9 @@ public function test_create_alert_rejects_get_method() { // Destructive ability: stream/purge-records (DELETE). // ------------------------------------------------------------------- + /** + * DELETE on a destructive+idempotent ability dispatches successfully. + */ public function test_purge_records_returns_200_for_admin_with_filters() { wp_set_current_user( $this->admin_user_id ); @@ -258,6 +267,8 @@ public function test_purge_records_rejects_post_method() { // ------------------------------------------------------------------- /** + * Unknown ability slugs route to a 404 from the core run controller. + * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_unknown_ability_returns_404() { From 4b2616eb6c93935d2676dcf965b36140a4f54640 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Thu, 7 May 2026 14:46:55 +0530 Subject: [PATCH 24/24] Address Copilot follow-up: bool checkboxes, purge error surfacing, idempotent category, doc fix Four issues from the latest Copilot review on this branch: - update-settings: PHP booleans on checkbox keys were silently coerced to '' by Settings::sanitize_setting_by_field_type() (it gates on is_numeric()). JSON clients naturally send true/false for checkbox-typed settings, so the round-trip would store '' instead of 0/1. Walk the registered fields to identify checkbox keys and normalize bools to 0/1 before sanitization. Add a regression test that round-trips both true and false. - purge-records: the multi-table DELETE result was discarded, so a database-side failure (lock-wait timeout, deadlock, etc.) would still return the pre-counted 'deleted' as if the purge had succeeded. Check $wpdb->query()'s return value and surface a 500 WP_Error on false. - class-abilities: register_category() now bails when the category is already registered, mirroring the idempotency pattern in register_abilities() and avoiding a core _doing_it_wrong notice when multiple loader instances exist (which the test harness already works around). - get-records: the orderby description claimed unknown values fall back to ID in Query::query(), but the schema enum rejects them at REST validation. Tighten the description to match. --- abilities/class-ability-get-records.php | 2 +- abilities/class-ability-purge-records.php | 18 ++++++++++- abilities/class-ability-update-settings.php | 30 +++++++++++++++---- classes/class-abilities.php | 8 +++++ .../test-class-ability-update-settings.php | 29 ++++++++++++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php index 430e86716..c40e128f4 100644 --- a/abilities/class-ability-get-records.php +++ b/abilities/class-ability-get-records.php @@ -150,7 +150,7 @@ public function get_input_schema() { ), 'orderby' => array( 'type' => 'string', - 'description' => 'Column to order by. Must be one of Stream\'s sortable columns; unknown values fall back to ID in Query::query().', + 'description' => 'Column to order by. Constrained to Stream\'s sortable columns by the schema enum; values outside the enum are rejected at REST validation.', 'enum' => array( 'ID', 'created', diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index 526911269..4c8e149d2 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -193,7 +193,23 @@ public function execute( $input = null ) { LEFT JOIN {$wpdb->streammeta} AS meta ON meta.record_id = stream.ID WHERE {$where_sql}"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared - $wpdb->query( $wpdb->prepare( $delete_sql, $params ) ); + $result = $wpdb->query( $wpdb->prepare( $delete_sql, $params ) ); + + // $wpdb->query() returns false on database error. Don't report a + // successful purge in that case: the COUNT we ran above said how many + // rows *would* be removed, but the DELETE may have aborted partway + // through (e.g. lock-wait timeout, deadlock). Surface the failure so + // callers can retry rather than believing the operation completed. + if ( false === $result ) { + return new \WP_Error( + 'stream_purge_failed', + __( 'Failed to purge Stream records due to a database error.', 'stream' ), + array( + 'status' => 500, + 'matched_count' => $deleted, + ) + ); + } return array( 'deleted' => $deleted ); } diff --git a/abilities/class-ability-update-settings.php b/abilities/class-ability-update-settings.php index 8dbf52123..50457d315 100644 --- a/abilities/class-ability-update-settings.php +++ b/abilities/class-ability-update-settings.php @@ -55,7 +55,7 @@ public function get_input_schema() { 'properties' => array( 'settings' => array( 'type' => 'object', - 'description' => 'Partial settings map keyed by {section}_{field} (e.g. general_records_ttl). Unknown keys are ignored (the request fails only when no key matches a registered setting); recognized values are normalized through Stream\'s settings sanitizer. Omitted keys are preserved.', + 'description' => 'Partial settings map keyed by {section}_{field} (e.g. general_records_ttl). Unknown keys are ignored (the request fails only when no key matches a registered setting); recognized values are normalized through Stream\'s settings sanitizer. Boolean values are accepted for checkbox keys and stored as 0/1. Omitted keys are preserved.', 'additionalProperties' => true, 'minProperties' => 1, ), @@ -84,15 +84,26 @@ public function execute( $input = null ) { $current = (array) get_option( $option_key, array() ); $updates = isset( $input['settings'] ) ? (array) $input['settings'] : array(); - // Build allowlist of {section}_{field} keys from registered settings. - $valid_keys = array(); + // Build allowlist of {section}_{field} keys from registered settings, + // and a parallel list of which keys correspond to checkbox fields. + // We need the latter because Settings::sanitize_setting_by_field_type() + // only accepts numeric values for checkboxes (is_numeric() rejects PHP + // booleans), so true/false from a JSON client would otherwise be + // silently coerced to '' instead of 0/1. + $valid_keys = array(); + $checkbox_keys = array(); foreach ( $this->plugin->settings->get_fields() as $section => $section_data ) { if ( empty( $section_data['fields'] ) || ! is_array( $section_data['fields'] ) ) { continue; } foreach ( $section_data['fields'] as $field ) { - if ( ! empty( $field['name'] ) ) { - $valid_keys[] = $section . '_' . $field['name']; + if ( empty( $field['name'] ) ) { + continue; + } + $key = $section . '_' . $field['name']; + $valid_keys[] = $key; + if ( isset( $field['type'] ) && 'checkbox' === $field['type'] ) { + $checkbox_keys[] = $key; } } } @@ -107,6 +118,15 @@ public function execute( $input = null ) { ); } + // Normalize PHP booleans on checkbox keys to 0/1 so the sanitizer + // (which uses is_numeric()) accepts them. JSON clients naturally send + // true/false for checkboxes; without this they would round-trip to ''. + foreach ( $checkbox_keys as $key ) { + if ( array_key_exists( $key, $filtered ) && is_bool( $filtered[ $key ] ) ) { + $filtered[ $key ] = $filtered[ $key ] ? 1 : 0; + } + } + // Run only the incoming keys through Stream's sanitize pipeline so // values are normalized to their declared field type, then merge over // the existing options so unrelated keys are preserved. diff --git a/classes/class-abilities.php b/classes/class-abilities.php index 33dff4914..fd6c6cf17 100644 --- a/classes/class-abilities.php +++ b/classes/class-abilities.php @@ -138,6 +138,14 @@ public function register_category() { return; } + // Skip when the category is already registered. Without this guard, + // re-running the bootstrap (e.g. multiple loader instances in tests) + // triggers a core _doing_it_wrong notice. Mirrors the idempotency + // pattern in register_abilities(). + if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( self::CATEGORY_SLUG ) ) { + return; + } + wp_register_ability_category( self::CATEGORY_SLUG, array( diff --git a/tests/phpunit/abilities/test-class-ability-update-settings.php b/tests/phpunit/abilities/test-class-ability-update-settings.php index 644347c24..ee82ad21b 100644 --- a/tests/phpunit/abilities/test-class-ability-update-settings.php +++ b/tests/phpunit/abilities/test-class-ability-update-settings.php @@ -101,6 +101,35 @@ public function test_rejects_unknown_setting_keys() { $this->assertSame( 'stream_no_valid_settings', $result->get_error_code() ); } + public function test_boolean_values_for_checkbox_keys_are_normalized_to_one_zero() { + wp_set_current_user( $this->admin_user_id ); + + $option_key = $this->plugin->settings->option_key; + + // JSON-native boolean true must round-trip to 1, not '' (which is what + // Settings::sanitize_setting_by_field_type() would produce for a + // raw bool because it gates on is_numeric()). + $result = $this->ability->execute( + array( + 'settings' => array( 'advanced_wp_cron_tracking' => true ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( 1, $result['advanced_wp_cron_tracking'] ); + $this->assertSame( 1, (int) get_option( $option_key )['advanced_wp_cron_tracking'] ); + + // And boolean false must round-trip to 0. + $result = $this->ability->execute( + array( + 'settings' => array( 'advanced_wp_cron_tracking' => false ), + ) + ); + + $this->assertSame( 0, $result['advanced_wp_cron_tracking'] ); + $this->assertSame( 0, (int) get_option( $option_key )['advanced_wp_cron_tracking'] ); + } + public function test_unknown_keys_are_dropped_when_mixed_with_valid() { wp_set_current_user( $this->admin_user_id );