diff --git a/abilities/class-ability-create-alert.php b/abilities/class-ability-create-alert.php new file mode 100644 index 000000000..c39b03ed9 --- /dev/null +++ b/abilities/class-ability-create-alert.php @@ -0,0 +1,195 @@ + 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' ), + ); + } + + /** + * {@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} + * + * @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'; + + // 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, + ), + true + ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + 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..222d6fed4 --- /dev/null +++ b/abilities/class-ability-create-exclusion-rule.php @@ -0,0 +1,228 @@ + 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' ), + ); + } + + /** + * {@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.', + 'maxLength' => 100, + ), + 'connector' => array( + 'type' => 'string', + '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, + ), + ), + ); + } + + /** + * {@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} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + 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 ); + } + + // 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', + __( '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() ); + + $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 = $sanitized[ $column ]; + $rules[ $column ][ $index ] = $value; + $rule[ $column ] = '' === $value ? null : $value; + } + + $options['exclude_rules'] = $rules; + update_option( $option_key, $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, + 'rule' => $rule, + ); + } +} diff --git a/abilities/class-ability-delete-alert.php b/abilities/class-ability-delete-alert.php new file mode 100644 index 000000000..b1978aff6 --- /dev/null +++ b/abilities/class-ability-delete-alert.php @@ -0,0 +1,104 @@ + 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' ), + ); + } + + /** + * {@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} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + public function execute( $input = null ) { + $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-get-alerts.php b/abilities/class-ability-get-alerts.php new file mode 100644 index 000000000..0cfcfc94c --- /dev/null +++ b/abilities/class-ability-get-alerts.php @@ -0,0 +1,145 @@ + 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' ), + ); + } + + /** + * {@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} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + public function execute( $input = null ) { + $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 ) { + // 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 ) || array() === $alert_meta ) { + $alert_meta = new \stdClass(); + } + + $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' => $alert_meta, + ); + } + + return $out; + } +} diff --git a/abilities/class-ability-get-connectors.php b/abilities/class-ability-get-connectors.php new file mode 100644 index 000000000..e75703596 --- /dev/null +++ b/abilities/class-ability-get-connectors.php @@ -0,0 +1,114 @@ + 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' ), + ); + } + + /** + * {@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} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + public function execute( $input = null ) { + 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..0a633c740 --- /dev/null +++ b/abilities/class-ability-get-exclusion-rules.php @@ -0,0 +1,97 @@ + 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' ), + ); + } + + /** + * {@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} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + public function execute( $input = null ) { + 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..aed5c9bf7 --- /dev/null +++ b/abilities/class-ability-get-record.php @@ -0,0 +1,143 @@ + 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' ), + ); + } + + /** + * {@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} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + public function execute( $input = null ) { + global $wpdb; + + $id = isset( $input['id'] ) ? (int) $input['id'] : 0; + + // 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. 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() && ! is_network_admin() ) { + $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( + 'stream_record_not_found', + __( 'Record not found.', 'stream' ), + array( 'status' => 404 ) + ); + } + + $row['meta'] = (array) get_metadata( 'record', $row['ID'] ); + + return $row; + } +} diff --git a/abilities/class-ability-get-records.php b/abilities/class-ability-get-records.php new file mode 100644 index 000000000..c40e128f4 --- /dev/null +++ b/abilities/class-ability-get-records.php @@ -0,0 +1,261 @@ + 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' ), + ); + } + + /** + * {@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' ), + 'maxItems' => 100, + 'description' => 'Match any of these user IDs (max 100).', + ), + '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' ), + 'maxItems' => 100, + 'description' => 'Match any of these connector slugs (max 100).', + ), + '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. Constrained to Stream\'s sortable columns by the schema enum; values outside the enum are rejected at REST validation.', + 'enum' => array( + 'ID', + 'created', + 'user_id', + 'user_role', + 'summary', + 'connector', + 'context', + 'action', + 'site_id', + 'blog_id', + 'object_id', + ), + 'default' => 'created', + ), + ), + ); + } + + /** + * {@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} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + public function execute( $input = null ) { + $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..a01b03738 --- /dev/null +++ b/abilities/class-ability-get-settings.php @@ -0,0 +1,75 @@ + 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' ), + ); + } + + /** + * {@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). 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.', + ); + } + + /** + * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + 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 new file mode 100644 index 000000000..4c8e149d2 --- /dev/null +++ b/abilities/class-ability-purge-records.php @@ -0,0 +1,216 @@ + 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' ), + ); + } + + /** + * {@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. Associated meta rows are removed in the same multi-table DELETE.', + 'minimum' => 0, + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + public function execute( $input = null ) { + 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(); + $filter_count = 0; + + if ( ! empty( $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'] ) ) { + $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 ( 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' ), + array( 'status' => 400 ) + ); + } + + // 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(); + } + + $where_sql = implode( ' AND ', $where ); + + // 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 ); + } + + // 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 + $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 new file mode 100644 index 000000000..50457d315 --- /dev/null +++ b/abilities/class-ability-update-settings.php @@ -0,0 +1,146 @@ + 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' ), + ); + } + + /** + * {@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} (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, + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + public function get_output_schema() { + return array( + 'type' => 'object', + 'description' => 'The complete settings array after the update.', + 'additionalProperties' => true, + ); + } + + /** + * {@inheritDoc} + * + * @param mixed $input Validated input matching get_input_schema(), or null. + */ + 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(); + + // 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'] ) ) { + continue; + } + $key = $section . '_' . $field['name']; + $valid_keys[] = $key; + if ( isset( $field['type'] ) && 'checkbox' === $field['type'] ) { + $checkbox_keys[] = $key; + } + } + } + + // 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 ) + ); + } + + // 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. + $sanitized = $this->plugin->settings->sanitize_settings( $filtered ); + $merged = array_merge( $current, $sanitized ); + + update_option( $option_key, $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 $this->plugin->settings->options; + } +} diff --git a/abilities/trait-view-stream-permission.php b/abilities/trait-view-stream-permission.php new file mode 100644 index 000000000..baf5a7727 --- /dev/null +++ b/abilities/trait-view-stream-permission.php @@ -0,0 +1,26 @@ + + */ + 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_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; + } + + /** + * 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; + } + + // 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( + '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+). + * + * @return bool + */ + public function is_available() { + return class_exists( '\WP_Ability' ); + } + + /** + * 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; + + 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 ] ); + } + + /** + * 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 ) { + // 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/classes/class-ability.php b/classes/class-ability.php new file mode 100644 index 000000000..fc7394011 --- /dev/null +++ b/classes/class-ability.php @@ -0,0 +1,153 @@ +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. + * + * 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 = null ); + + /** + * 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( WP_STREAM_SETTINGS_CAPABILITY ); + } + + /** + * Annotation flags for the ability (readonly, destructive, idempotent). + * + * @return array + */ + public function get_annotations() { + return array(); + } + + /** + * Meta passed to wp_register_ability(). Sets REST exposure and (optionally) annotations. + * + * @return array + */ + public function get_meta() { + $meta = array( + '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(), + 'category' => Abilities::CATEGORY_SLUG, + '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..c8a50aa06 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -400,6 +400,29 @@ public function get_fields() { array_push( $fields['advanced']['fields'], $wp_cron_tracking ); + // 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' ), + '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 the Abilities API REST routes. 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 * 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 @@ + + + + + + + + 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..b168573a1 --- /dev/null +++ b/tests/phpunit/abilities/abilities-testcase.php @@ -0,0 +1,179 @@ +settings->options at setUp() time, restored in tearDown(). + * + * @var array + */ + private $options_snapshot = array(); + + /** + * {@inheritDoc} + */ + public function setUp(): void { + parent::setUp(); + + if ( ! class_exists( '\WP_Ability' ) ) { + $this->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' ) ); + + // 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(); + } + + /** + * 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. + * + * 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-create-alert.php b/tests/phpunit/abilities/test-class-ability-create-alert.php new file mode 100644 index 000000000..9cc4112a3 --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-create-alert.php @@ -0,0 +1,165 @@ +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 ); + + // 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', + '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_connector'] ); + $this->assertSame( '', $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_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 ); + + // 'sms' is not a registered notifier; the ability should reject before insert. + $result = $this->ability->execute( + array( + 'alert_type' => 'sms', + '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 ); + + $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..71111283e --- /dev/null +++ b/tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php @@ -0,0 +1,141 @@ +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 ); + } + + 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 ); + } +} diff --git a/tests/phpunit/abilities/test-rest-integration.php b/tests/phpunit/abilities/test-rest-integration.php new file mode 100644 index 000000000..037d11a75 --- /dev/null +++ b/tests/phpunit/abilities/test-rest-integration.php @@ -0,0 +1,304 @@ +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). + // ------------------------------------------------------------------- + + /** + * 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 ); + + $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). + // ------------------------------------------------------------------- + + /** + * 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 ); + + $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). + // ------------------------------------------------------------------- + + /** + * 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 ); + + $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. + // ------------------------------------------------------------------- + + /** + * 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() { + 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/fake-ability.php b/tests/phpunit/fake-ability.php new file mode 100644 index 000000000..0f3d00643 --- /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 = null ) { + $this->last_input = $input; + return 'ok'; + } +} diff --git a/tests/phpunit/test-class-abilities.php b/tests/phpunit/test-class-abilities.php new file mode 100644 index 000000000..5aff16c74 --- /dev/null +++ b/tests/phpunit/test-class-abilities.php @@ -0,0 +1,287 @@ +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; + } + + /** + * {@inheritDoc} + */ + 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(); + } + + 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() ); + } + + /** + * 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 ); + } + }//end try + } + + /** + * 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 ); + } + }//end try + } + + 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_load_abilities_populates_all_slugs() { + $abilities = new Abilities( $this->plugin ); + + $abilities->load_abilities(); + + $this->assertCount( 11, $abilities->abilities ); + foreach ( $abilities->get_ability_slugs() as $slug ) { + $this->assertArrayHasKey( 'stream/' . $slug, $abilities->abilities ); + } + } + + 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).' ); + } + + // Enable the setting so the constructor wires both category + abilities hooks. + $this->plugin->settings->options['advanced_enable_abilities_api'] = 1; + + $abilities = new Abilities( $this->plugin ); + + // 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->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 new file mode 100644 index 000000000..e1ecf08ff --- /dev/null +++ b/tests/phpunit/test-class-ability.php @@ -0,0 +1,117 @@ +ability = new Fake_Ability_For_Test( $this->plugin ); + } + + public function test_get_meta_exposes_in_rest_without_annotations_by_default() { + $meta = $this->ability->get_meta(); + $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_has_ability' ) ) { + $this->markTestSkipped( 'Requires WordPress 6.9+ (Abilities API).' ); + } + + // 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 ); + } + + 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(); + } +}