diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b84a8..bf186fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0 + +- Images can again be uploaded from raw file data, not only from a URL — an image with no publicly reachable address could not be uploaded at all +- Plugin tools now reject an empty plugin slug up front instead of failing partway through +- Navigation and page-revision tools describe the fields they return again, so a client knows the shape of the response + ## 0.3.6 - AI clients can now authenticate with a WordPress Application Password sent in the `Authorization` header (HTTPS required) diff --git a/README.md b/README.md index 31dd69e..da9a495 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ |---|---| | **Plugin name** | Airo WP AI Builder | | **Text domain** | `airo-wp` | -| **Version** | 0.3.6 | +| **Version** | 0.4.0 | | **Requires WordPress** | 6.9+ | | **Requires PHP** | 7.4+ | | **License** | [GPLv2 or later](https://www.gnu.org/licenses/gpl-2.0.html) | diff --git a/airo-wp.php b/airo-wp.php index 6d9a74a..e59c4fa 100644 --- a/airo-wp.php +++ b/airo-wp.php @@ -3,7 +3,7 @@ * Plugin Name: Airo WP AI Builder * Plugin URI: https://github.com/godaddy-wordpress/airo-wp * Description: MCP server and block pattern library for AI-powered site building. - * Version: 0.3.6 + * Version: 0.4.0 * Requires at least: 6.9 * Requires PHP: 7.4 * Author: GoDaddy @@ -47,7 +47,7 @@ defined( 'ABSPATH' ) || exit; if ( ! defined( 'AIRO_WP_VERSION' ) ) { - define( 'AIRO_WP_VERSION', '0.3.6' ); + define( 'AIRO_WP_VERSION', '0.4.0' ); } if ( ! defined( 'AIRO_WP_PLUGIN_FILE' ) ) { define( 'AIRO_WP_PLUGIN_FILE', __FILE__ ); diff --git a/composer.lock b/composer.lock index 1748d0a..a67a747 100644 --- a/composer.lock +++ b/composer.lock @@ -1184,21 +1184,21 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "b598aa890815b8df16363271b659d73280129101" + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", - "reference": "b598aa890815b8df16363271b659d73280129101", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.2.0", + "phpcsstandards/phpcsutils": "^1.2.3", "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { @@ -1262,20 +1262,20 @@ "type": "thanks_dev" } ], - "time": "2025-11-12T23:06:57+00:00" + "time": "2026-07-27T11:13:17+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", - "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f", "shasum": "" }, "require": { @@ -1355,7 +1355,7 @@ "type": "thanks_dev" } ], - "time": "2025-12-08T14:27:58+00:00" + "time": "2026-07-27T10:28:41+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2856,16 +2856,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.5", + "version": "3.13.6", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", "shasum": "" }, "require": { @@ -2931,7 +2931,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-04T16:30:35+00:00" + "time": "2026-08-06T00:17:32+00:00" }, { "name": "theseer/tokenizer", @@ -2985,16 +2985,16 @@ }, { "name": "wp-coding-standards/wpcs", - "version": "3.3.0", + "version": "3.4.1", "source": { "type": "git", "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", - "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6" + "reference": "ec2ff942335f33683a5957a85d138753876a05cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", - "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf", "shasum": "" }, "require": { @@ -3003,9 +3003,9 @@ "ext-tokenizer": "*", "ext-xmlreader": "*", "php": ">=7.2", - "phpcsstandards/phpcsextra": "^1.5.0", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.4" + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", @@ -3047,7 +3047,7 @@ "type": "custom" } ], - "time": "2025-11-25T12:08:04+00:00" + "time": "2026-07-27T11:53:23+00:00" } ], "aliases": [], diff --git a/functions/blocks/field-render-helpers.php b/functions/blocks/field-render-helpers.php index e43ca21..da71d7e 100644 --- a/functions/blocks/field-render-helpers.php +++ b/functions/blocks/field-render-helpers.php @@ -6,7 +6,7 @@ * produced here at render time). Centralising the wrapper width, label and * help-text markup keeps every field's render.php byte-consistent and means * the authored (or pattern-substituted / translated) field text is emitted - * server-side — so a pattern (or the site-designer-api) can substitute or + * server-side — so a pattern (or the page generator) can substitute or * translate field text without ever tripping block validation. * * (Field labels are not translated here: block.json supplies each label's diff --git a/includes/Blocks/Common/Forms/FormHandler.php b/includes/Blocks/Common/Forms/FormHandler.php index 817db88..b0f3cf6 100644 --- a/includes/Blocks/Common/Forms/FormHandler.php +++ b/includes/Blocks/Common/Forms/FormHandler.php @@ -98,6 +98,11 @@ class FormHandler { */ private FormSecurity $security; + /** + * Transient holding definitions for forms that live outside wp_posts. + */ + const EXTERNAL_DEFINITIONS_CACHE = 'airowp_form_external_definitions_v1'; + /** * Constructor. */ @@ -120,6 +125,10 @@ public function __construct() { // Invalidate cached form block attributes when posts are saved. add_action( 'save_post', array( $this, 'clear_form_attributes_cache' ) ); + + // Forms outside wp_posts (block widgets, theme-file templates, patterns). + add_action( 'update_option_widget_block', array( $this, 'clear_external_form_definitions' ) ); + add_action( 'switch_theme', array( $this, 'clear_external_form_definitions' ) ); } /** @@ -268,12 +277,21 @@ public function handle_form_submission( $request ) { $timestamp = $request->get_param( 'timestamp' ); $form_settings = $this->get_form_settings(); - // Look up per-block attributes for rate limiting and Turnstile enforcement. - // Fallback (2/60s) is intentionally stricter than the block-level default - // — it only applies to orphaned/legacy submissions where the per-block - // configuration is missing. - $block_attrs = $this->get_form_block_attributes( $form_id ); - $rate_limit_count = isset( $block_attrs['rateLimitCount'] ) ? absint( $block_attrs['rateLimitCount'] ) : 2; + // Resolve the complete published form definition before running checks that + // can consume resources. A form ID is an untrusted client value: accepting + // an unknown one would let callers bypass its field schema and per-form + // controls entirely. + $form_definition = $this->get_form_definition( $form_id ); + if ( null === $form_definition ) { + return new WP_Error( + 'unknown_form', + __( 'This form is no longer available.', 'airo-wp' ), + array( 'status' => 404 ) + ); + } + + $block_attrs = $form_definition['attributes']; + $rate_limit_count = isset( $block_attrs['rateLimitCount'] ) ? absint( $block_attrs['rateLimitCount'] ) : 3; $rate_limit_window = isset( $block_attrs['rateLimitWindow'] ) ? absint( $block_attrs['rateLimitWindow'] ) : 60; $turnstile_required = ! empty( $block_attrs['enableTurnstile'] ); @@ -298,6 +316,11 @@ public function handle_form_submission( $request ) { if ( is_wp_error( $rate_limit_check ) ) { return $rate_limit_check; } + + $global_rate_limit_check = $this->security->check_global_rate_limit(); + if ( is_wp_error( $global_rate_limit_check ) ) { + return $global_rate_limit_check; + } } // Turnstile verification. @@ -327,8 +350,8 @@ public function handle_form_submission( $request ) { } // Sanitize and validate all fields. - $form_field_types = $this->get_form_field_types( $form_id ); - $field_constraints = $this->get_form_field_value_constraints( $form_id ); + $form_field_types = $form_definition['field_types']; + $field_constraints = $form_definition['constraints']; $sanitized_fields = array(); foreach ( $fields as $field ) { if ( ! isset( $field['name'] ) || ! isset( $field['value'] ) ) { @@ -338,9 +361,17 @@ public function handle_form_submission( $request ) { $field_name = sanitize_text_field( $field['name'] ); $field_value = $field['value']; $submitted_field_type = isset( $field['type'] ) ? sanitize_text_field( $field['type'] ) : 'text'; - $field_type = isset( $form_field_types[ $field_name ] ) - ? $form_field_types[ $field_name ] - : $submitted_field_type; + + // Only fields the saved form declares are validated, stored and + // emailed. Anything else is dropped rather than failing the whole + // submission: Cloudflare Turnstile injects cf-turnstile-response + // inside the form, other plugins add hidden inputs of their own, and + // none of that is the visitor's data. + if ( ! isset( $form_field_types[ $field_name ] ) && ! empty( $form_field_types ) ) { + continue; + } + + $field_type = isset( $form_field_types[ $field_name ] ) ? $form_field_types[ $field_name ] : $submitted_field_type; // Server-defined allowed values for constrained field types (only // present for select/checkbox/hidden fields resolved from the block). @@ -375,6 +406,16 @@ public function handle_form_submission( $request ) { ); } + foreach ( $form_definition['required_fields'] as $field_name ) { + if ( ! isset( $sanitized_fields[ $field_name ] ) || $this->is_empty_field_value( $sanitized_fields[ $field_name ]['value'] ) ) { + return new WP_Error( + 'required_field_missing', + __( 'Please complete all required fields.', 'airo-wp' ), + array( 'status' => 400 ) + ); + } + } + // Store submission. $submission_id = $this->store_submission( $form_id, $sanitized_fields ); @@ -392,6 +433,7 @@ public function handle_form_submission( $request ) { // Increment rate limit counter ONLY after successful submission. if ( $form_settings['enable_rate_limiting'] ) { $this->security->increment_rate_limit( $form_id, $rate_limit_window ); + $this->security->increment_global_rate_limit(); } // Trigger action hook for email notifications, integrations, etc. @@ -407,6 +449,20 @@ public function handle_form_submission( $request ) { ); } + /** + * Determine whether a sanitized field value should fail a required check. + * + * @param mixed $value Sanitized field value. + * @return bool True when the value is empty. + */ + private function is_empty_field_value( $value ) { + if ( is_array( $value ) ) { + return empty( $value ); + } + + return '' === trim( (string) $value ); + } + /** * Handle form submission via admin-ajax.php (fallback for rate-limited REST API). * @@ -607,6 +663,16 @@ private function validate_field( $value, $type, $allowed = null ) { ); } break; + + case 'country_code': + // Every entry in form-phone-field/country-codes.js is "+" and 1-4 digits. + if ( ! is_string( $value ) || ! preg_match( '/^\+\d{1,4}$/', $value ) ) { + return new WP_Error( + 'invalid_country_code', + __( 'Invalid country code.', 'airo-wp' ) + ); + } + break; } return true; @@ -1106,13 +1172,40 @@ public function cleanup_old_submissions() { * @return array|null Block attributes array, or null if not found. */ private function get_form_block_attributes( $form_id ) { - // Check transient cache first to avoid LIKE queries on every submission. - // v2 prefix invalidates older caches that were stored before block-type - // defaults were merged into parsed attributes. - $cache_key = 'airowp_form_attrs_v2_' . md5( $form_id ); + // Preserve the legacy attributes cache for installations that already have + // it populated. New lookups use the complete definition cache below. + $legacy_cache = get_transient( 'airowp_form_attrs_v2_' . md5( $form_id ) ); + if ( false !== $legacy_cache && is_array( $legacy_cache ) ) { + return $legacy_cache; + } + + $form_definition = $this->get_form_definition( $form_id ); + + return null === $form_definition ? null : $form_definition['attributes']; + } + + /** + * Resolve the server-owned definition for a public form. + * + * Parsing the post once keeps the validation schema, required flags, and + * submission configuration in sync. Only published posts are eligible: a + * private or draft form must not become a public submission endpoint just + * because its predictable ID is known. Forms that never live in wp_posts — + * block widgets, templates still served from theme or plugin files, and + * registered patterns — are resolved from those sources instead. + * + * @param string $form_id Form identifier to look up. + * @return array{attributes: array, field_types: array, constraints: array, required_fields: string[]}|null Form definition or null. + */ + private function get_form_definition( $form_id ) { + if ( ! is_string( $form_id ) || '' === $form_id ) { + return null; + } + + $cache_key = 'airowp_form_definition_v2_' . md5( $form_id ); $cached = get_transient( $cache_key ); - if ( false !== $cached ) { + if ( false !== $cached && is_array( $cached ) ) { return $cached; } @@ -1129,7 +1222,7 @@ private function get_form_block_attributes( $form_id ) { "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_content LIKE %s AND post_content LIKE %s - AND post_status IN ('publish', 'private') + AND post_status = 'publish' LIMIT 5", // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter '%' . $wpdb->esc_like( 'airo-wp/form-builder' ) . '%', @@ -1138,221 +1231,230 @@ private function get_form_block_attributes( $form_id ) { ) ); - if ( empty( $posts ) ) { - return null; - } + foreach ( (array) $posts as $post ) { + $blocks = parse_blocks( $post->post_content ); + $form_block = $this->find_form_block( $blocks, $form_id ); + if ( null !== $form_block ) { + $definition = $this->build_form_definition( $form_block ); - foreach ( $posts as $post ) { - $blocks = parse_blocks( $post->post_content ); - $attrs = $this->find_form_block_attributes( $blocks, $form_id ); - if ( null !== $attrs ) { - // Cache for 1 hour. Invalidated on save_post via clear_form_attributes_cache(). - set_transient( $cache_key, $attrs, HOUR_IN_SECONDS ); - return $attrs; + set_transient( $cache_key, $definition, HOUR_IN_SECONDS ); + return $definition; } } - return null; + return $this->get_external_form_definition( $form_id ); } /** - * Recursively search parsed blocks for a form-builder block with matching formId. + * Build the server-owned definition for a parsed form block. * - * @param array $blocks Parsed blocks array. - * @param string $form_id Form identifier to match. - * @return array|null Block attributes if found, null otherwise. + * @param array $form_block Parsed airo-wp/form-builder block. + * @return array{attributes: array, field_types: array, constraints: array, required_fields: string[]} Form definition. */ - private function find_form_block_attributes( $blocks, $form_id ) { - foreach ( $blocks as $block ) { - if ( - 'airo-wp/form-builder' === $block['blockName'] && - isset( $block['attrs']['formId'] ) && - $block['attrs']['formId'] === $form_id - ) { - // parse_blocks() returns only the attributes that were serialized into - // the block comment. The editor omits attributes that equal their - // declared default, so booleans like `enableEmail` (default true) and - // similar may be missing here. Merge in the block-type defaults so - // server-side consumers see the same attribute set the editor does. - return $this->apply_form_block_defaults( $block['attrs'] ); - } + private function build_form_definition( array $form_block ) { + $inner_blocks = isset( $form_block['innerBlocks'] ) ? $form_block['innerBlocks'] : array(); + + return array( + 'attributes' => $this->apply_form_block_defaults( $form_block['attrs'] ), + 'field_types' => $this->extract_field_types_from_blocks( $inner_blocks ), + 'constraints' => $this->extract_field_value_constraints_from_blocks( $inner_blocks ), + 'required_fields' => $this->extract_required_field_names_from_blocks( $inner_blocks ), + ); + } - if ( ! empty( $block['innerBlocks'] ) ) { - $result = $this->find_form_block_attributes( $block['innerBlocks'], $form_id ); - if ( null !== $result ) { - return $result; + /** + * Resolve a form that lives outside wp_posts. + * + * Block widgets, templates and template parts still served from theme or + * plugin files, and registered patterns (which templates pull in with + * wp:pattern) never appear in wp_posts. All of them are site-owner + * content. The index is built once and cached, so an unknown form ID costs + * a transient read here rather than a rescan of every template and pattern. + * + * @param string $form_id Form identifier to look up. + * @return array|null Form definition, or null when no such form exists. + */ + private function get_external_form_definition( $form_id ) { + $definitions = get_transient( self::EXTERNAL_DEFINITIONS_CACHE ); + + if ( ! is_array( $definitions ) ) { + $definitions = array(); + foreach ( $this->get_external_block_content() as $content ) { + if ( ! is_string( $content ) || false === strpos( $content, 'airo-wp/form-builder' ) ) { + continue; + } + foreach ( $this->find_form_blocks( parse_blocks( $content ) ) as $form_block ) { + $id = (string) $form_block['attrs']['formId']; + if ( ! isset( $definitions[ $id ] ) ) { + $definitions[ $id ] = $this->build_form_definition( $form_block ); + } } } + set_transient( self::EXTERNAL_DEFINITIONS_CACHE, $definitions, HOUR_IN_SECONDS ); } - return null; + return isset( $definitions[ $form_id ] ) ? $definitions[ $form_id ] : null; } /** - * Merge registered block-type attribute defaults into a parsed attributes array. + * Collect block content that isn't stored as a post. * - * @param array $attrs Parsed block attributes from parse_blocks(). - * @return array Attributes with block.json defaults filled in for missing keys. + * @return string[] Serialized block content. */ - private function apply_form_block_defaults( $attrs ) { - if ( ! class_exists( '\WP_Block_Type_Registry' ) ) { - return $attrs; - } + private function get_external_block_content() { + $contents = array(); - $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'airo-wp/form-builder' ); - if ( ! $block_type || ! is_array( $block_type->attributes ) ) { - return $attrs; + foreach ( (array) get_option( 'widget_block', array() ) as $widget ) { + if ( is_array( $widget ) && isset( $widget['content'] ) ) { + $contents[] = $widget['content']; + } } - foreach ( $block_type->attributes as $key => $schema ) { - if ( array_key_exists( $key, $attrs ) ) { - continue; + if ( function_exists( 'get_block_templates' ) ) { + foreach ( array( 'wp_template', 'wp_template_part' ) as $template_type ) { + foreach ( get_block_templates( array(), $template_type ) as $template ) { + $contents[] = $template->content; + } } - if ( is_array( $schema ) && array_key_exists( 'default', $schema ) ) { - $attrs[ $key ] = $schema['default']; + } + + foreach ( \WP_Block_Patterns_Registry::get_instance()->get_all_registered() as $pattern ) { + if ( isset( $pattern['content'] ) ) { + $contents[] = $pattern['content']; } } - return $attrs; + return $contents; } /** - * Look up server-defined field types for a form by form ID. + * Recursively collect every form block that carries a form ID. * - * Uses parsed block content so validation/sanitization does not rely on - * client-supplied field types. - * - * @param string $form_id Form identifier to look up. - * @return array Field types keyed by field name. + * @param array $blocks Parsed blocks. + * @return array[] Parsed airo-wp/form-builder blocks. */ - private function get_form_field_types( $form_id ) { - $cache_key = 'airowp_form_field_types_' . md5( $form_id ); - $cached = get_transient( $cache_key ); - - if ( false !== $cached && is_array( $cached ) ) { - return $cached; - } - - global $wpdb; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - $posts = $wpdb->get_results( - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - $wpdb->prepare( - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - "SELECT ID, post_content FROM {$wpdb->posts} - WHERE post_content LIKE %s - AND post_content LIKE %s - AND post_status IN ('publish', 'private') - LIMIT 5", - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - '%' . $wpdb->esc_like( 'airo-wp/form-builder' ) . '%', - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - '%' . $wpdb->esc_like( '"formId":"' . $form_id . '"' ) . '%' - ) - ); + private function find_form_blocks( $blocks ) { + $forms = array(); - if ( empty( $posts ) ) { - return array(); - } - - foreach ( $posts as $post ) { - $blocks = parse_blocks( $post->post_content ); - $field_types = $this->find_form_field_types( $blocks, $form_id ); + foreach ( $blocks as $block ) { + if ( + 'airo-wp/form-builder' === $block['blockName'] && + isset( $block['attrs']['formId'] ) && + is_string( $block['attrs']['formId'] ) && + '' !== $block['attrs']['formId'] + ) { + $forms[] = $block; + } - if ( ! empty( $field_types ) ) { - set_transient( $cache_key, $field_types, HOUR_IN_SECONDS ); - return $field_types; + if ( ! empty( $block['innerBlocks'] ) ) { + $forms = array_merge( $forms, $this->find_form_blocks( $block['innerBlocks'] ) ); } } - return array(); + return $forms; } /** - * Look up server-defined allowed values for constrained fields by form ID. + * Drop the cached index of forms outside wp_posts. * - * Parallels get_form_field_types() but returns, per field name, the list of - * values the server will accept. Only select/checkbox/hidden fields are - * constrained; all other field types are omitted (unconstrained). Used to - * reject forged option values and hidden-field constants from the client. + * Hooked to widget and theme changes; template edits are covered by + * clear_form_attributes_cache(). + */ + public function clear_external_form_definitions() { + delete_transient( self::EXTERNAL_DEFINITIONS_CACHE ); + } + + /** + * Recursively find a form block with its inner-block schema intact. * - * @param string $form_id Form identifier to look up. - * @return array Allowed values keyed by field name. + * @param array $blocks Parsed blocks array. + * @param string $form_id Form identifier to match. + * @return array|null Matching parsed block. */ - private function get_form_field_value_constraints( $form_id ) { - $cache_key = 'airowp_form_field_constraints_' . md5( $form_id ); - $cached = get_transient( $cache_key ); + private function find_form_block( $blocks, $form_id ) { + foreach ( $blocks as $block ) { + if ( + 'airo-wp/form-builder' === $block['blockName'] && + isset( $block['attrs']['formId'] ) && + $form_id === $block['attrs']['formId'] + ) { + return $block; + } - if ( false !== $cached && is_array( $cached ) ) { - return $cached; + if ( ! empty( $block['innerBlocks'] ) ) { + $result = $this->find_form_block( $block['innerBlocks'], $form_id ); + if ( null !== $result ) { + return $result; + } + } } - global $wpdb; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - $posts = $wpdb->get_results( - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - $wpdb->prepare( - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - "SELECT ID, post_content FROM {$wpdb->posts} - WHERE post_content LIKE %s - AND post_content LIKE %s - AND post_status IN ('publish', 'private') - LIMIT 5", - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - '%' . $wpdb->esc_like( 'airo-wp/form-builder' ) . '%', - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter - '%' . $wpdb->esc_like( '"formId":"' . $form_id . '"' ) . '%' - ) - ); + return null; + } - if ( empty( $posts ) ) { - return array(); + /** + * Merge registered block-type attribute defaults into a parsed attributes array. + * + * @param array $attrs Parsed block attributes from parse_blocks(). + * @return array Attributes with block.json defaults filled in for missing keys. + */ + private function apply_form_block_defaults( $attrs ) { + $schemas = array(); + $block_type = class_exists( '\WP_Block_Type_Registry' ) + ? \WP_Block_Type_Registry::get_instance()->get_registered( 'airo-wp/form-builder' ) + : null; + + if ( $block_type && is_array( $block_type->attributes ) ) { + $schemas = $block_type->attributes; + } elseif ( function_exists( 'wp_json_file_decode' ) ) { + $metadata = wp_json_file_decode( + dirname( __DIR__, 3 ) . '/src/blocks/form-builder/block.json', + array( 'associative' => true ) + ); + $schemas = is_array( $metadata ) && isset( $metadata['attributes'] ) && is_array( $metadata['attributes'] ) + ? $metadata['attributes'] + : array(); } - foreach ( $posts as $post ) { - $blocks = parse_blocks( $post->post_content ); - $constraints = $this->find_form_field_constraints( $blocks, $form_id ); - - if ( ! empty( $constraints ) ) { - set_transient( $cache_key, $constraints, HOUR_IN_SECONDS ); - return $constraints; + foreach ( $schemas as $key => $schema ) { + if ( array_key_exists( $key, $attrs ) ) { + continue; + } + if ( is_array( $schema ) && array_key_exists( 'default', $schema ) ) { + $attrs[ $key ] = $schema['default']; } } - return array(); + return $attrs; } /** - * Recursively search parsed blocks for a form-builder block and extract - * allowed values for constrained fields. + * Extract names of required fields from a form's inner blocks. * - * @param array $blocks Parsed blocks array. - * @param string $form_id Form identifier to match. - * @return array Allowed values keyed by field name. + * @param array $blocks Parsed inner blocks. + * @return string[] Required field names. */ - private function find_form_field_constraints( $blocks, $form_id ) { + private function extract_required_field_names_from_blocks( $blocks ) { + $required_fields = array(); + foreach ( $blocks as $block ) { - if ( - 'airo-wp/form-builder' === $block['blockName'] && - isset( $block['attrs']['formId'] ) && - $block['attrs']['formId'] === $form_id - ) { - return $this->extract_field_value_constraints_from_blocks( - isset( $block['innerBlocks'] ) ? $block['innerBlocks'] : array() - ); + $attrs = isset( $block['attrs'] ) ? $block['attrs'] : array(); + $field_name = isset( $attrs['fieldName'] ) ? sanitize_text_field( $attrs['fieldName'] ) : ''; + $field_type = $this->map_block_name_to_field_type( isset( $block['blockName'] ) ? $block['blockName'] : '' ); + + if ( $field_name && $field_type && ! empty( $attrs['required'] ) ) { + $required_fields[] = $field_name; } if ( ! empty( $block['innerBlocks'] ) ) { - $result = $this->find_form_field_constraints( $block['innerBlocks'], $form_id ); - if ( ! empty( $result ) ) { - return $result; - } + $required_fields = array_merge( + $required_fields, + $this->extract_required_field_names_from_blocks( $block['innerBlocks'] ) + ); } } - return array(); + return array_values( array_unique( $required_fields ) ); } /** @@ -1414,36 +1516,6 @@ private function extract_field_value_constraints_from_blocks( $blocks ) { return $constraints; } - /** - * Recursively search parsed blocks for a form-builder block and extract field types. - * - * @param array $blocks Parsed blocks array. - * @param string $form_id Form identifier to match. - * @return array Field types keyed by field name. - */ - private function find_form_field_types( $blocks, $form_id ) { - foreach ( $blocks as $block ) { - if ( - 'airo-wp/form-builder' === $block['blockName'] && - isset( $block['attrs']['formId'] ) && - $block['attrs']['formId'] === $form_id - ) { - return $this->extract_field_types_from_blocks( - isset( $block['innerBlocks'] ) ? $block['innerBlocks'] : array() - ); - } - - if ( ! empty( $block['innerBlocks'] ) ) { - $result = $this->find_form_field_types( $block['innerBlocks'], $form_id ); - if ( ! empty( $result ) ) { - return $result; - } - } - } - - return array(); - } - /** * Extract field types from a form block's inner blocks. * @@ -1460,6 +1532,15 @@ private function extract_field_types_from_blocks( $blocks ) { if ( $field_name && $field_type ) { $field_types[ $field_name ] = $field_type; + + // form-phone-field renders a companion with inline options. */ const v3 = { diff --git a/src/blocks/form-select-field/deprecated.js b/src/blocks/form-select-field/deprecated.js index 7c4f6f5..ce43c54 100644 --- a/src/blocks/form-select-field/deprecated.js +++ b/src/blocks/form-select-field/deprecated.js @@ -51,7 +51,7 @@ const sharedAttributes = { // Sourced from the stored HTML (the empty-value option's text) rather than a // fixed default. The placeholder is the one piece of visible text that the // pattern bakes into the markup WITHOUT a matching block-comment attribute, - // so a translated / site-designer-substituted placeholder (e.g. "-- Choisir + // so a translated / generator-substituted placeholder (e.g. "-- Choisir // --") would otherwise never match save()'s output and block recovery would // fail. Sourcing it makes the deprecation reproduce the stored text exactly, // so migration is silent and the real placeholder is carried over. @@ -167,7 +167,7 @@ const vStatic = { /** * Version 2: Before aria-required was added to required fields. * - * The site-designer-api generates HTML without aria-required="true" on + * The page generator emits HTML without aria-required="true" on * required select fields. This deprecation matches that older format. */ const v2 = { diff --git a/src/blocks/form-text-field/deprecated.js b/src/blocks/form-text-field/deprecated.js index e658630..774497d 100644 --- a/src/blocks/form-text-field/deprecated.js +++ b/src/blocks/form-text-field/deprecated.js @@ -161,7 +161,7 @@ const vStatic = { /** * Version 1: Before aria-required was added to required fields. * - * The site-designer-api generated HTML without aria-required="true" on required + * The page generator emitted HTML without aria-required="true" on required * input fields. Kept for provenance; vStatic already covers this content. */ const v1 = { diff --git a/src/blocks/form-textarea-field/deprecated.js b/src/blocks/form-textarea-field/deprecated.js index 1e356fb..e7c2106 100644 --- a/src/blocks/form-textarea-field/deprecated.js +++ b/src/blocks/form-textarea-field/deprecated.js @@ -141,7 +141,7 @@ const vStatic = { /** * Version 2: Before aria-required was added to required fields. * - * The site-designer-api generates HTML without aria-required="true" on + * The page generator emits HTML without aria-required="true" on * required textarea fields. This deprecation matches that older format. */ const v2 = { diff --git a/src/blocks/grid/deprecated.js b/src/blocks/grid/deprecated.js index f40c91a..79af572 100644 --- a/src/blocks/grid/deprecated.js +++ b/src/blocks/grid/deprecated.js @@ -489,7 +489,7 @@ const v1 = { }; /** - * Site-designer responsive-grid markup where the tablet column count lived in a + * Generator-emitted responsive-grid markup where the tablet column count lived in a * `className` (e.g. `airo-wp-grid-cols-tablet-1`) rather than the `tabletColumns` * attribute — and the block comment's `tabletColumns` drifted away from it. * diff --git a/src/blocks/map/render.php b/src/blocks/map/render.php index 0c5f6d9..57a2868 100644 --- a/src/blocks/map/render.php +++ b/src/blocks/map/render.php @@ -6,7 +6,7 @@ * for view.js) is produced here at render time. Two wins over the old static * save: * - * 1. Nothing is stored to diff against, so a pattern (or the site-designer-api) + * 1. Nothing is stored to diff against, so a pattern (or the page generator) * can omit or change any value — e.g. the marker colour — without tripping * block validation. * 2. The marker colour resolves per-kit: explicit attribute → theme.json diff --git a/src/blocks/map/utils/geocoding.js b/src/blocks/map/utils/geocoding.js index 66953cf..a983d7e 100644 --- a/src/blocks/map/utils/geocoding.js +++ b/src/blocks/map/utils/geocoding.js @@ -12,6 +12,8 @@ */ function normalizeAddress(address) { return address + .replace(/u0022/g, '') // compat: " serialized as \" then stripped by wp_unslash → u0022 + .replace(/u0026quot;/g, '') // compat: " encoded as " then &→& by serializer, \→stripped by wp_unslash .split(/[\r\n]+/) .map((line) => line.trim()) .filter(Boolean) diff --git a/src/blocks/product-categories-grid/render.php b/src/blocks/product-categories-grid/render.php index 2ebf49a..c6fa794 100644 --- a/src/blocks/product-categories-grid/render.php +++ b/src/blocks/product-categories-grid/render.php @@ -26,7 +26,7 @@ * @param array $attributes Block attributes. * @param string $content Inner block content. * @param WP_Block $block Block instance. - * @return void + * @return string|void Rendered category grid markup or no output. */ function airowp_render_product_categories_grid( $attributes, $content, $block ) { // Bail if WooCommerce is not active. @@ -94,7 +94,6 @@ function airowp_render_product_categories_grid( $attributes, $content, $block ) 'orderby' => 'include', ) ); - } else { // All mode: exclude empty (optionally) and the actual "Uncategorized" category. diff --git a/src/blocks/product-showcase-hero/render.php b/src/blocks/product-showcase-hero/render.php index 4905b51..aa4a064 100644 --- a/src/blocks/product-showcase-hero/render.php +++ b/src/blocks/product-showcase-hero/render.php @@ -25,7 +25,7 @@ * @param array $attributes Block attributes. * @param string $content Inner block content. * @param WP_Block $block Block instance. - * @return void + * @return string|void Password form or rendered block output. */ function airowp_render_product_showcase_hero( $attributes, $content, $block ) { // Bail if WooCommerce is not active. @@ -73,7 +73,12 @@ function airowp_render_product_showcase_hero( $attributes, $content, $block ) { $show_add_to_cart = ! isset( $attributes['showAddToCart'] ) || $attributes['showAddToCart']; $show_variations = ! isset( $attributes['showVariations'] ) || $attributes['showVariations']; $min_height = isset( $attributes['minHeight'] ) ? $attributes['minHeight'] : '500px'; - $focal_point = isset( $attributes['mediaFocalPoint'] ) ? $attributes['mediaFocalPoint'] : array( 'x' => 0.5, 'y' => 0.5 ); + $focal_point = isset( $attributes['mediaFocalPoint'] ) + ? $attributes['mediaFocalPoint'] + : array( + 'x' => 0.5, + 'y' => 0.5, + ); $allowed_alignments = array( 'top', 'center', 'bottom' ); $vertical_alignment = isset( $attributes['contentVerticalAlignment'] ) && in_array( $attributes['contentVerticalAlignment'], $allowed_alignments, true ) ? $attributes['contentVerticalAlignment'] @@ -131,10 +136,11 @@ function airowp_render_product_showcase_hero( $attributes, $content, $block ) { // Set up global product for WooCommerce template functions. // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - global $post; - $original_post = $post; - $original_product = isset( $GLOBALS['product'] ) ? $GLOBALS['product'] : null; - $post = get_post( $product_id ); + global $post; + $original_post = $post; + $original_product = isset( $GLOBALS['product'] ) ? $GLOBALS['product'] : null; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- WooCommerce template functions require the global post object; restored below. + $post = get_post( $product_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- WooCommerce template functions require this global to be named exactly $product; swapped for loop context and restored below. $GLOBALS['product'] = $product; setup_postdata( $post ); diff --git a/src/blocks/query/hooks/useQueryPreview.js b/src/blocks/query/hooks/useQueryPreview.js index e0513c3..abae43d 100644 --- a/src/blocks/query/hooks/useQueryPreview.js +++ b/src/blocks/query/hooks/useQueryPreview.js @@ -160,7 +160,7 @@ export default function useQueryPreview({ setRestState((s) => ({ ...s, loading: true })); apiFetch({ - path: '/airo-wp/v1/query/render', + path: '/airo-wp/v1/query/render-preview', method: 'POST', data: { queryId, diff --git a/src/blocks/query/hooks/useRenderedItems.js b/src/blocks/query/hooks/useRenderedItems.js index 3dfa869..e6e766f 100644 --- a/src/blocks/query/hooks/useRenderedItems.js +++ b/src/blocks/query/hooks/useRenderedItems.js @@ -60,7 +60,7 @@ export default function useRenderedItems({ const timer = setTimeout(() => { apiFetch({ - path: '/airo-wp/v1/query/render', + path: '/airo-wp/v1/query/render-preview', method: 'POST', data: { queryId, diff --git a/src/blocks/query/render-helpers.php b/src/blocks/query/render-helpers.php index 9d29aef..8a42eec 100644 --- a/src/blocks/query/render-helpers.php +++ b/src/blocks/query/render-helpers.php @@ -289,7 +289,7 @@ function airowp_query_defaults( array $attributes ) { 'itemTagName' => 'li', 'emitSchema' => true, 'relationshipField' => '', - 'relationshipFallback' => 'empty', // empty | all | parent + 'relationshipFallback' => 'empty', // empty, all, or parent. 'groupBy' => null, ); return wp_parse_args( $attributes, $defaults ); @@ -305,7 +305,7 @@ function airowp_query_defaults( array $attributes ) { * Posts with no matching term land in the '__none__' / Uncategorized bucket. * * @param int[] $post_ids Ordered list of post IDs to partition. - * @param array $group_spec { field: string, key: string } + * @param array $group_spec { field: string, key: string }. * @return array[] Array of groups: each { label: string, value: string, ids: int[] } */ function airowp_query_partition_items( array $post_ids, array $group_spec ) { @@ -320,6 +320,15 @@ function airowp_query_partition_items( array $post_ids, array $group_spec ) { } $field = (string) $group_spec['field']; $key = (string) $group_spec['key']; + if ( 'meta' === $field && is_protected_meta( $key, 'post' ) ) { + return array( + array( + 'label' => __( 'Uncategorized', 'airo-wp' ), + 'value' => '__none__', + 'ids' => array_map( 'absint', $post_ids ), + ), + ); + } $groups = array(); foreach ( $post_ids as $pid ) { @@ -628,6 +637,12 @@ function airowp_query_get_last_state( $query_id ) { * block comment strings). The * helper splits them here. * - params (array) URL filter params. + * - refresh_source_post_id (int|null) Post whose + * content holds the query + * (0 outside post content), + * or null to emit no signed + * refresh source (editor + * preview, tests). * - wrapper_attrs (string|null) Pre-computed * get_block_wrapper_attributes() * string (first-paint only; @@ -638,11 +653,12 @@ function airowp_query_render_region( array $attributes, array $context ) { $context = wp_parse_args( $context, array( - 'query_id' => '', - 'page' => 1, - 'inner_html' => '', - 'params' => array(), - 'wrapper_attrs' => null, + 'query_id' => '', + 'page' => 1, + 'inner_html' => '', + 'params' => array(), + 'refresh_source_post_id' => null, + 'wrapper_attrs' => null, ) ); @@ -659,7 +675,8 @@ function airowp_query_render_region( array $attributes, array $context ) { (int) $context['page'], $query_id, 'class="airo-wp-query airo-wp-query-region airo-wp-query--source-' . sanitize_key( (string) ( $attributes['source'] ?? 'posts' ) ) . '"', - array() + array(), + null === $context['refresh_source_post_id'] ? null : absint( $context['refresh_source_post_id'] ) ); // totalPages/totalItems come from the state registry populated during @@ -687,19 +704,22 @@ function airowp_query_render_region( array $attributes, array $context ) { * child in tree order so filters/pagination/no-results appear exactly * where the author placed them. * - * @param array $attributes Raw block attributes. - * @param array $parsed_children parse_blocks() entries of the block's innerBlocks. - * @param int $page Current pagination page. - * @param string $query_id Sanitized queryId. - * @param string $wrapper_attrs Pre-computed get_block_wrapper_attributes() - * string for the outer element. IAPI attrs - * (data-wp-interactive, data-wp-context, - * data-airo-wp-query-id) are appended here. - * @param array $base_context WP_Block context inherited from the outer - * render path (passed to each child render). + * @param array $attributes Raw block attributes. + * @param array $parsed_children parse_blocks() entries of the block's innerBlocks. + * @param int $page Current pagination page. + * @param string $query_id Sanitized queryId. + * @param string $wrapper_attrs Pre-computed get_block_wrapper_attributes() + * string for the outer element. IAPI attrs + * (data-wp-interactive, data-wp-context, + * data-airo-wp-query-id) are appended here. + * @param array $base_context WP_Block context inherited from the outer + * render path (passed to each child render). + * @param int|null $refresh_source_post_id Post whose content holds the query + * (0 outside post content) for the signed + * refresh source, or null to emit none. * @return string Full HTML for the outer element, including children. */ - function airowp_query_render_container( array $attributes, array $parsed_children, $page, $query_id, $wrapper_attrs, array $base_context = array() ) { + function airowp_query_render_container( array $attributes, array $parsed_children, $page, $query_id, $wrapper_attrs, array $base_context = array(), $refresh_source_post_id = null ) { $attributes = airowp_query_defaults( $attributes ); $source = sanitize_key( (string) ( $attributes['source'] ?? 'posts' ) ); @@ -829,7 +849,7 @@ function airowp_query_render_container( array $attributes, array $parsed_childre // REST call still returns a usable region. if ( ! $results_child ) { $children_html_direct = (string) $result['html']; - $blobs = airowp_query_render_blobs( $query_id, $attributes, $parsed_children ); + $blobs = airowp_query_render_blobs( $query_id, $attributes, $parsed_children, $refresh_source_post_id ); $status = sprintf( '
', esc_attr( $query_id ), @@ -842,7 +862,7 @@ function airowp_query_render_container( array $attributes, array $parsed_childre 'page' => (int) $page, 'busy' => false, 'restUrl' => esc_url_raw( rest_url( 'airo-wp/v1/query/render' ) ), - 'nonce' => wp_create_nonce( 'wp_rest' ), + 'nonce' => airowp_query_rest_nonce(), ), JSON_HEX_APOS ); @@ -926,7 +946,7 @@ function airowp_query_render_container( array $attributes, array $parsed_childre 'page' => (int) $page, 'busy' => false, 'restUrl' => esc_url_raw( rest_url( 'airo-wp/v1/query/render' ) ), - 'nonce' => wp_create_nonce( 'wp_rest' ), + 'nonce' => airowp_query_rest_nonce(), ), JSON_HEX_APOS ); @@ -940,9 +960,8 @@ function airowp_query_render_container( array $attributes, array $parsed_childre $merged_wrapper = trim( (string) $wrapper_attrs . ' ' . $iapi_attrs ); - // Blobs + status for IAPI refresh. Blobs carry the attrs + serialized - // innerBlocks so the REST refresh can rebuild the same region. - $blobs = airowp_query_render_blobs( $query_id, $attributes, $parsed_children ); + // Signed definition the public refresh route renders from. + $blobs = airowp_query_render_blobs( $query_id, $attributes, $parsed_children, $refresh_source_post_id ); $status = sprintf( '
', esc_attr( $query_id ), @@ -963,36 +982,54 @@ function airowp_query_render_container( array $attributes, array $parsed_childre if ( ! function_exists( 'airowp_query_render_blobs' ) ) : /** - * Build the hidden blobs div (attrs + serialized innerBlocks) embedded - * alongside the query region. Referenced by view.js during filter/sort - * refresh so the REST payload can be reconstructed without a round-trip. + * Build the hidden, signed refresh source embedded in the query region. + * + * The view script sends it back verbatim on filter / sort / Load more, and + * the public REST route renders only a definition whose signature verifies + * — see airo-wp\Blocks\Query\RefreshSource. * - * @param string $query_id Sanitized queryId. - * @param array $attributes Query block attributes (already defaulted). - * @param array $parsed_children parse_blocks() entries. - * @return string HTML for the blobs div, or empty string when queryId is empty. + * @param string $query_id Sanitized queryId. + * @param array $attributes Query attributes (already defaulted). + * @param array $parsed_children parse_blocks() entries of the inner blocks. + * @param int|null $source_post_id Post whose content holds the query (0 + * outside post content), or null for none. + * @return string HTML for the blobs div, or empty string when not emitted. */ - function airowp_query_render_blobs( $query_id, array $attributes, array $parsed_children ) { - if ( '' === $query_id ) { + function airowp_query_render_blobs( $query_id, array $attributes, array $parsed_children, $source_post_id ) { + if ( '' === $query_id || null === $source_post_id || ! class_exists( 'airo-wp\\Blocks\\Query\\RefreshSource' ) ) { return ''; } - $flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; - $full_inner_html = ''; + $inner_blocks = ''; foreach ( $parsed_children as $child ) { - if ( ! empty( $child['blockName'] ) && function_exists( 'serialize_block' ) ) { - $full_inner_html .= serialize_block( $child ); + if ( ! empty( $child['blockName'] ) ) { + $inner_blocks .= serialize_block( $child ); } } - return ''; + $signed = \airo-wp\Blocks\Query\RefreshSource::sign( $query_id, $attributes, $inner_blocks, (int) $source_post_id ); + + return ''; + } + +endif; + +if ( ! function_exists( 'airowp_query_rest_nonce' ) ) : + + /** + * REST nonce for the refresh request — logged-in users only. + * + * A cached page outlives its nonce (12–24h), and core rejects a stale + * X-WP-Nonce with rest_cookie_invalid_nonce before the route's own + * permission check runs. The public route needs no nonce, so visitors get + * none; logged-in users need one so the route can see who they are. + * + * @return string Nonce, or empty string for visitors. + */ + function airowp_query_rest_nonce() { + return is_user_logged_in() ? wp_create_nonce( 'wp_rest' ) : ''; } endif; diff --git a/src/blocks/query/render-relationship.php b/src/blocks/query/render-relationship.php index 29b3d5e..85cf55f 100644 --- a/src/blocks/query/render-relationship.php +++ b/src/blocks/query/render-relationship.php @@ -22,6 +22,13 @@ if ( ! function_exists( 'airowp_query_render_relationship' ) ) : + /** + * Render posts referenced by a relationship field on the current parent item. + * + * @param array $atts Query attributes. + * @param array $context Query render context. + * @return array Rendered query result and pagination metadata. + */ function airowp_query_render_relationship( array $atts, array $context ) { $field = isset( $atts['relationshipField'] ) ? sanitize_text_field( (string) $atts['relationshipField'] ) : ''; if ( '' === $field ) { @@ -85,6 +92,9 @@ function airowp_query_render_relationship( array $atts, array $context ) { /** * Normalize ACF/meta field values to a list of post IDs. + * + * @param mixed $value Raw relationship value. + * @return int[] Referenced post IDs. */ function airowp_query_relationship_normalize_ids( $value ) { if ( is_array( $value ) ) { @@ -118,6 +128,10 @@ function airowp_query_relationship_normalize_ids( $value ) { /** * Empty / all / parent fallback for no-result relationship queries. + * + * @param array $atts Query attributes. + * @param array $context Query render context. + * @return array Rendered query result and pagination metadata. */ function airowp_query_relationship_fallback( array $atts, array $context ) { $mode = isset( $atts['relationshipFallback'] ) ? (string) $atts['relationshipFallback'] : 'empty'; diff --git a/src/blocks/query/render-terms.php b/src/blocks/query/render-terms.php index 6b99b8f..233fd73 100644 --- a/src/blocks/query/render-terms.php +++ b/src/blocks/query/render-terms.php @@ -10,6 +10,13 @@ if ( ! function_exists( 'airowp_query_render_terms' ) ) : + /** + * Render taxonomy terms for a Dynamic Query. + * + * @param array $atts Query attributes. + * @param array $context Query render context. + * @return array Rendered query result and pagination metadata. + */ function airowp_query_render_terms( array $atts, array $context ) { $per_page = max( 1, (int) $atts['perPage'] ); $page = max( 1, (int) $context['page'] ); @@ -107,6 +114,9 @@ function airowp_query_render_terms( array $atts, array $context ) { /** * Whitelist orderby values acceptable to get_terms(). + * + * @param string $orderby Requested orderby value. + * @return string Safe get_terms() orderby value. */ function airowp_query_sanitize_term_orderby( $orderby ) { $allowed = array( 'name', 'slug', 'term_group', 'term_id', 'id', 'description', 'parent', 'count', 'include', 'slug__in', 'meta_value', 'meta_value_num', 'none' ); diff --git a/src/blocks/query/render-users.php b/src/blocks/query/render-users.php index bcd2544..d2c64ab 100644 --- a/src/blocks/query/render-users.php +++ b/src/blocks/query/render-users.php @@ -10,6 +10,13 @@ if ( ! function_exists( 'airowp_query_render_users' ) ) : + /** + * Render user records for a Dynamic Query. + * + * @param array $atts Query attributes. + * @param array $context Query render context. + * @return array Rendered query result and pagination metadata. + */ function airowp_query_render_users( array $atts, array $context ) { $per_page = max( 1, (int) $atts['perPage'] ); $page = max( 1, (int) $context['page'] ); @@ -29,7 +36,14 @@ function airowp_query_render_users( array $atts, array $context ) { if ( '' !== $search ) { // Wildcard search like WP_User_Query default. $args['search'] = '*' . $search . '*'; - $args['search_columns'] = array( 'user_login', 'user_email', 'user_nicename', 'display_name' ); + $args['search_columns'] = array( 'user_login', 'user_nicename', 'display_name' ); + // Match core's users endpoint: email is searchable only by people who + // can list users. Anyone else — every visitor to a public directory + // with a search box — could otherwise test whether an address belongs + // to an account. + if ( current_user_can( 'list_users' ) ) { + $args['search_columns'][] = 'user_email'; + } } /** This filter is documented in src/blocks/query/render-posts.php */ @@ -81,6 +95,9 @@ function airowp_query_render_users( array $atts, array $context ) { * * The block's `orderBy` attribute defaults to `date` (a post orderby), so * map that to a sensible user equivalent. + * + * @param string $orderby Requested orderby value. + * @return string Safe WP_User_Query orderby value. */ function airowp_query_sanitize_user_orderby( $orderby ) { $allowed = array( 'ID', 'id', 'user_registered', 'registered', 'display_name', 'name', 'login', 'user_login', 'nicename', 'user_nicename', 'email', 'user_email' ); @@ -90,6 +107,11 @@ function airowp_query_sanitize_user_orderby( $orderby ) { if ( 'title' === $orderby ) { return 'display_name'; } + // Match core's users endpoint: sorting by email needs list_users, or the + // order leaks addresses to anyone who can register and see where they land. + if ( in_array( $orderby, array( 'email', 'user_email' ), true ) && ! current_user_can( 'list_users' ) ) { + return 'display_name'; + } return in_array( $orderby, $allowed, true ) ? $orderby : 'user_registered'; } diff --git a/src/blocks/query/render.php b/src/blocks/query/render.php index 93fb989..0feff07 100644 --- a/src/blocks/query/render.php +++ b/src/blocks/query/render.php @@ -59,5 +59,6 @@ $airowp_page, $airowp_query_id, $airowp_wrapper_attrs, - (array) ( $block->context ?? array() ) + (array) ( $block->context ?? array() ), + class_exists( 'airo-wp\\Blocks\\Query\\RefreshSource' ) ? \airo-wp\Blocks\Query\RefreshSource::current_content_post_id() : 0 ); diff --git a/src/blocks/query/view-helpers.js b/src/blocks/query/view-helpers.js index 2f0b35f..c653ad8 100644 --- a/src/blocks/query/view-helpers.js +++ b/src/blocks/query/view-helpers.js @@ -100,6 +100,7 @@ export function notifyItemsAppended(container, queryId, added) { detail: { queryId, added }, }) ); + notifyContentUpdated(container, 'query-append'); } // --------------------------------------------------------------------------- @@ -263,6 +264,65 @@ export function announceResultCount( statusEl.setAttribute('data-airo-wp-total-items', String(n)); } +/** + * Read the signed refresh source a query region carries. + * + * First paint embeds the query's definition and an HMAC signature; the + * public REST route renders only a definition whose signature verifies, so + * the pair is sent back verbatim. + * + * @param {Element|null} blobsHost The region's `[data-airo-wp-blobs-for]` element. + * @return {{source: string, signature: string}|null} Signed source, or null. + */ +export function readRefreshSource(blobsHost) { + const source = blobsHost?.getAttribute('data-airo-wp-refresh-source') || ''; + const signature = blobsHost?.getAttribute('data-airo-wp-signature') || ''; + return source && signature ? { source, signature } : null; +} + +/** + * Build the fetch() arguments for a query refresh. + * + * X-WP-Nonce goes only with a nonce the region itself carries, which the + * server emits for logged-in users alone: a cached page outlives its nonce, + * and core rejects a stale one before the route runs. A page-wide + * wpApiSettings nonce is never borrowed for the same reason. + * + * @param {Object} ctx IAPI context (queryId, restUrl, nonce). + * @param {Object} refreshSource Result of readRefreshSource(). + * @param {Object} request Request details. + * @param {number} request.page Page to render. + * @param {Object} request.params Filter params (see collectParams()). + * @param {string} request.currentUrl URL the results are for. + * @return {{url: string, init: Object}} fetch() URL and options. + */ +export function buildRefreshRequest(ctx, refreshSource, request) { + const headers = { 'Content-Type': 'application/json' }; + if (ctx?.nonce) { + headers['X-WP-Nonce'] = ctx.nonce; + } + + return { + url: + ctx?.restUrl || + (window.wpApiSettings?.root || '/wp-json/') + + 'airo-wp/v1/query/render', + init: { + method: 'POST', + credentials: 'same-origin', + headers, + body: JSON.stringify({ + queryId: ctx?.queryId, + source: refreshSource.source, + signature: refreshSource.signature, + page: request.page, + params: request.params, + currentUrl: request.currentUrl, + }), + }, + }; +} + /** * Build the filter params object from a URL's search params. Collects only * filter_*, q, and sort keys — extensions can add more via the server-side diff --git a/src/blocks/query/view.js b/src/blocks/query/view.js index 76a9533..221a502 100644 --- a/src/blocks/query/view.js +++ b/src/blocks/query/view.js @@ -25,6 +25,8 @@ import { notifyItemsAppended as dsgoNotifyItemsAppended, markHandledEvent as dsgoMarkHandledEvent, isHandledEvent as dsgoIsHandledEvent, + readRefreshSource as dsgoReadRefreshSource, + buildRefreshRequest as dsgoBuildRefreshRequest, } from './view-helpers.js'; // Query IDs with an in-flight delegated refresh. The delegated handlers build @@ -273,16 +275,6 @@ function dsgoGetQueryContainer(queryId, el) { return region?.querySelector(selector) || doc.querySelector(selector); } -function dsgoGetRestConfig(ctx) { - return { - restUrl: - ctx.restUrl || - (window.wpApiSettings?.root || '/wp-json/') + - 'airo-wp/v1/query/render', - restNonce: ctx.nonce || window.wpApiSettings?.nonce || '', - }; -} - async function dsgoLoadMorePlain(ctx, button) { if (!ctx?.queryId || ctx.busy || !(button instanceof HTMLElement)) { return; @@ -315,37 +307,23 @@ async function dsgoLoadMorePlain(ctx, button) { const blobsHost = document.querySelector( `[data-airo-wp-blobs-for="${ctx.queryId}"]` ); - const attrsEl = blobsHost?.querySelector('script[data-airo-wp-attrs]'); - const innerEl = blobsHost?.querySelector('script[data-airo-wp-inner]'); - - if (!attrsEl || !innerEl) { + const refreshSource = dsgoReadRefreshSource(blobsHost); + if (!refreshSource) { return; } - const attributes = JSON.parse(attrsEl.textContent); - const innerBlocks = JSON.parse(innerEl.textContent); const nextPage = (ctx.page || 1) + 1; - const { restUrl, restNonce } = dsgoGetRestConfig(ctx); - const res = await fetch(restUrl, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': restNonce, - }, - body: JSON.stringify({ - queryId: ctx.queryId, - attributes, - page: nextPage, - innerBlocks, - currentUrl: window.location.href, - }), + const request = dsgoBuildRefreshRequest(ctx, refreshSource, { + page: nextPage, + params: dsgoCollectParams(new URL(window.location.href)), + currentUrl: window.location.href, }); + const res = await fetch(request.url, request.init); if (!res.ok) { // eslint-disable-next-line no-console console.warn( - `[airo-wp/query] load-more request failed (${res.status}). If 401, the nonce has likely expired — reload the page.` + `[airo-wp/query] load-more request failed (${res.status}). Reloading the page will refresh it.` ); return; } @@ -748,43 +726,28 @@ function* dsgoQueryRefresh(ctx, url) { } try { - const attrsEl = blobsHost.querySelector('script[data-airo-wp-attrs]'); - const innerEl = blobsHost.querySelector('script[data-airo-wp-inner]'); - if (!attrsEl || !innerEl) { + // Filters live in the URL, so when the in-place refresh can't run (no + // signed source, or the server refused it — a cached page whose signature + // predates a salt change, say) the server-rendered page is the fallback. + const refreshSource = dsgoReadRefreshSource(blobsHost); + if (!refreshSource) { + window.location.assign(url.toString()); return; } - const attributes = JSON.parse(attrsEl.textContent); - const innerBlocks = JSON.parse(innerEl.textContent); - const params = dsgoCollectParams(url); - - const restUrl = - ctx.restUrl || - (window.wpApiSettings?.root || '/wp-json/') + - 'airo-wp/v1/query/render'; - const restNonce = ctx.nonce || window.wpApiSettings?.nonce || ''; - const res = yield fetch(restUrl, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': restNonce, - }, - body: JSON.stringify({ - queryId, - attributes, - page: 1, - innerBlocks, - params, - currentUrl: url.toString(), - }), + const request = dsgoBuildRefreshRequest(ctx, refreshSource, { + page: 1, + params: dsgoCollectParams(url), + currentUrl: url.toString(), }); + const res = yield fetch(request.url, request.init); if (!res.ok) { // eslint-disable-next-line no-console console.warn( - `[airo-wp/query] filter refresh failed (${res.status}). If 401, the nonce has likely expired — reload the page.` + `[airo-wp/query] filter refresh failed (${res.status}); loading the filtered page instead.` ); + window.location.assign(url.toString()); return; } // Defence-in-depth: only parse responses we recognise. A misbehaving @@ -883,43 +846,26 @@ async function dsgoQueryRefreshPlain(ctx, url) { } try { - const attrsEl = blobsHost.querySelector('script[data-airo-wp-attrs]'); - const innerEl = blobsHost.querySelector('script[data-airo-wp-inner]'); - if (!attrsEl || !innerEl) { + // Same fallback as dsgoQueryRefresh: the server renders the filtered page. + const refreshSource = dsgoReadRefreshSource(blobsHost); + if (!refreshSource) { + window.location.assign(url.toString()); return; } - const attributes = JSON.parse(attrsEl.textContent); - const innerBlocks = JSON.parse(innerEl.textContent); - const params = dsgoCollectParams(url); - - const restUrl = - ctx.restUrl || - (window.wpApiSettings?.root || '/wp-json/') + - 'airo-wp/v1/query/render'; - const restNonce = ctx.nonce || window.wpApiSettings?.nonce || ''; - const res = await fetch(restUrl, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': restNonce, - }, - body: JSON.stringify({ - queryId, - attributes, - page: 1, - innerBlocks, - params, - currentUrl: url.toString(), - }), + const request = dsgoBuildRefreshRequest(ctx, refreshSource, { + page: 1, + params: dsgoCollectParams(url), + currentUrl: url.toString(), }); + const res = await fetch(request.url, request.init); if (!res.ok) { // eslint-disable-next-line no-console console.warn( - `[airo-wp/query] debounced refresh failed (${res.status}). If 401, the nonce has likely expired — reload the page.` + `[airo-wp/query] debounced refresh failed (${res.status}); loading the filtered page instead.` ); + window.location.assign(url.toString()); return; } // Defence-in-depth: see dsgoQueryRefresh — only inject from JSON envelopes. diff --git a/src/blocks/scroll-accordion/view.js b/src/blocks/scroll-accordion/view.js index ad5730c..a39d428 100644 --- a/src/blocks/scroll-accordion/view.js +++ b/src/blocks/scroll-accordion/view.js @@ -4,12 +4,23 @@ * Based on scroll position within page */ -/* global requestAnimationFrame */ +/* global requestAnimationFrame, cancelAnimationFrame */ + +const activeAccordions = new Map(); + +function cleanupDisconnectedAccordions() { + activeAccordions.forEach((cleanup, accordion) => { + if (!accordion.isConnected) { + cleanup(); + } + }); +} /** * Initialize all scroll accordions on the page */ function initScrollAccordions() { + cleanupDisconnectedAccordions(); const accordions = document.querySelectorAll('.airo-wp-scroll-accordion'); if (!accordions.length) { @@ -57,6 +68,7 @@ function initScrollAccordions() { // Track scroll position and apply scaling let ticking = false; + let frameId = null; function updateCards() { // Performance: Use cached viewport dimensions @@ -83,12 +95,17 @@ function initScrollAccordions() { }); ticking = false; + frameId = null; } // Throttle scroll events with requestAnimationFrame function requestTick() { + if (!accordion.isConnected) { + cleanup(); + return; + } if (!ticking) { - requestAnimationFrame(updateCards); + frameId = requestAnimationFrame(updateCards); ticking = true; } } @@ -107,6 +124,19 @@ function initScrollAccordions() { // Listen for scroll and resize events window.addEventListener('scroll', requestTick, { passive: true }); window.addEventListener('resize', handleResize, { passive: true }); + const cleanup = () => { + window.removeEventListener('scroll', requestTick); + window.removeEventListener('resize', handleResize); + clearTimeout(resizeTimer); + if ( + null !== frameId && + typeof cancelAnimationFrame === 'function' + ) { + cancelAnimationFrame(frameId); + } + activeAccordions.delete(accordion); + }; + activeAccordions.set(accordion, cleanup); // Initial check updateCards(); diff --git a/src/blocks/scroll-slides/render.php b/src/blocks/scroll-slides/render.php index ab5e673..25476ee 100644 --- a/src/blocks/scroll-slides/render.php +++ b/src/blocks/scroll-slides/render.php @@ -153,7 +153,7 @@ function airowp_render_scroll_slides( $attributes, $content, $block ) { $inner_style = ''; if ( ! empty( $atts['constrainWidth'] ) ) { - $max_width = $atts['contentWidth'] !== '' + $max_width = '' !== $atts['contentWidth'] ? airowp_safe_css_value( $atts['contentWidth'] ) : 'var(--wp--style--global--content-size, 1140px)'; if ( '' === $max_width ) { diff --git a/src/blocks/slider/render.php b/src/blocks/slider/render.php index f67536d..d619292 100644 --- a/src/blocks/slider/render.php +++ b/src/blocks/slider/render.php @@ -181,7 +181,7 @@ function airowp_render_slider( $attributes, $content, $block ) { 'role' => 'region', // Literal fallback mirrors save.js (which does not translate it). // If save.js ever translates the default, match that change here too. - 'aria-label' => $atts['ariaLabel'] !== '' ? $atts['ariaLabel'] : 'Image slider', + 'aria-label' => '' !== $atts['ariaLabel'] ? $atts['ariaLabel'] : 'Image slider', 'aria-roledescription' => 'slider', ) ); diff --git a/src/blocks/tabs/test/view.test.js b/src/blocks/tabs/test/view.test.js index a458f53..edfd624 100644 --- a/src/blocks/tabs/test/view.test.js +++ b/src/blocks/tabs/test/view.test.js @@ -10,7 +10,7 @@ */ const TABS_HTML = ` -
+
@@ -65,4 +65,24 @@ describe('tabs deep linking', () => { const first = el.querySelector('#panel-one'); expect(first.classList.contains('is-active')).toBe(true); }); + + it('keeps nested tab panels out of the outer navigation', () => { + const el = mount(''); + el.classList.add('airo-wp-tabs'); + el.innerHTML = + '
' + + '
' + + '
'; + + const tabs = new window.DSGTabs(el); + + expect( + el.querySelectorAll( + ':scope > .airo-wp-tabs__nav .airo-wp-tabs__tab' + ) + ).toHaveLength(2); + tabs.setActiveTab(1); + expect(el.querySelector('#panel-outer-one').hidden).toBe(true); + expect(el.querySelector('#panel-outer-two').hidden).toBe(false); + }); }); diff --git a/src/blocks/tabs/view.js b/src/blocks/tabs/view.js index 07d5230..8d95ed7 100644 --- a/src/blocks/tabs/view.js +++ b/src/blocks/tabs/view.js @@ -11,7 +11,9 @@ constructor(element) { this.element = element; this.nav = element.querySelector('.airo-wp-tabs__nav'); - this.panels = element.querySelectorAll('.airo-wp-tab'); + this.panels = Array.from( + element.querySelectorAll('.airo-wp-tab') + ).filter((panel) => panel.closest('.airo-wp-tabs') === element); this.activeTab = this.clampTabIndex( parseInt(element.dataset.activeTab, 10) ); diff --git a/tests/TestCase.php b/tests/TestCase.php index 3e23c8e..bc7bf49 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -9,6 +9,10 @@ namespace GoDaddy\WordPress\Plugins\AiroWp\Tests; +use GoDaddy\WordPress\Plugins\AiroWp\Container; +use GoDaddy\WordPress\Plugins\AiroWp\Dependencies\Psr\Container\ContainerInterface; +use GoDaddy\WordPress\Plugins\AiroWp\Internal\DependencyManagement\TestingContainer; + use Brain\Monkey; use PHPUnit\Framework\TestCase as PHPUnitTestCase; @@ -32,4 +36,50 @@ protected function tearDown(): void { Monkey\tearDown(); parent::tearDown(); } + + /** + * Invoke a private or protected method on an object. + * + * Tool schemas are declared in private get_input_schema() / get_output_schema() + * methods, and wp_register_ability() cannot be stubbed (the Strauss-bundled + * Abilities API defines it before Patchwork loads), so reflection is the only + * way to assert a tool's declared schema contract. + * + * @param object $target Object to call on. + * @param string $method_name Method name. + * @param array $args Positional arguments. + * @return mixed + */ + protected function call_private( object $target, string $method_name, array $args = array() ) { + $method = new \ReflectionMethod( $target, $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( $target, $args ); + } + + /** + * Build a Container backed by a TestingContainer, with optional replacements. + * + * Container self-registers itself (and ContainerInterface) only on its default + * construction path. When a pre-built runtime container is supplied — the test + * seam — it does not, so any service that takes a Container parameter fails to + * resolve. This helper seeds those two entries so the test seam behaves like the + * production path. + * + * @param array $replacements Instances to substitute, keyed by class name. + * @return Container + */ + protected function make_container( array $replacements = array() ): Container { + $testing_container = new TestingContainer( array() ); + $container = new Container( $testing_container ); + + $testing_container->replace( Container::class, $container ); + $testing_container->replace( ContainerInterface::class, $container ); + + foreach ( $replacements as $class_name => $instance ) { + $testing_container->replace( $class_name, $instance ); + } + + return $container; + } } diff --git a/tests/Unit/Mcp/Infrastructure/AbilitiesApiProxyTest.php b/tests/Unit/Mcp/Infrastructure/AbilitiesApiProxyTest.php index 8471acd..46ea02e 100644 --- a/tests/Unit/Mcp/Infrastructure/AbilitiesApiProxyTest.php +++ b/tests/Unit/Mcp/Infrastructure/AbilitiesApiProxyTest.php @@ -11,135 +11,50 @@ use Brain\Monkey\Actions; use Brain\Monkey\Functions; +use GoDaddy\WordPress\Plugins\AiroWp\Container; +use GoDaddy\WordPress\Plugins\AiroWp\Internal\DependencyManagement\RuntimeContainer; use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Infrastructure\AbilitiesApiProxy; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Plugins\ActivatePlugin; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Plugins\DeactivatePlugin; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Plugins\GetPlugin; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Plugins\ListPlugins; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Plugins\UpdatePlugin; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Site\SiteInfo; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Site\UpdateSiteOptions; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Themes\ActivateTheme; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Themes\GetThemes; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Themes\SwitchTheme; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\CreatePost; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\UpdatePost; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\DeletePost; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\ListPosts; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\GetPost; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\GetPostByOptionName; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\ListPostRevisions; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\RestorePostRevision; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Posts\UpdatePostImageAltText; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Pages\CreatePageDraft; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Pages\PublishPageDraft; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Pages\DiscardPageDraft; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Pages\GetPageDraftStatus; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Pages\ListPageRevisions; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Pages\GetPageRevision; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Pages\DeletePageRevision; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Media\DeleteMedia; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Media\GetAllMedia; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Media\GetMediaById; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Media\ListMedia; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Media\UpdateMediaMeta; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Media\UploadImage; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Templates\ListTemplates; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Templates\UpdateTemplate; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Templates\DeleteTemplate; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Templates\ListTemplateParts; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Templates\UpdateTemplatePart; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Templates\DeleteTemplatePart; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Templates\ListTemplateRevisions; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Templates\ListTemplatePartRevisions; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\GlobalStyles\GetGlobalStyles; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\GlobalStyles\ListGlobalStyles; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\GlobalStyles\ListGlobalStylesRevisions; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\GlobalStyles\UpdateGlobalStyles; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\GlobalStyles\GetBlockTypes; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\GlobalStyles\GetBlockPatterns; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Navigation\CreateNavigation; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Navigation\DeleteNavigation; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Navigation\GetNavigation; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Navigation\ListNavigationRevisions; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Navigation\ListNavigations; -use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Tools\Navigation\UpdateNavigation; +use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Infrastructure\ToolRegistry; use GoDaddy\WordPress\Plugins\AiroWp\Tests\TestCase; /** * Tests for AbilitiesApiProxy. + * + * ToolRegistry and Container are both final, so these use the real classes over a + * RuntimeContainer seeded with stub tools rather than mocking the collaborators. + * That also means the proxy is exercised against the actual tool list. */ final class AbilitiesApiProxyTest extends TestCase { /** - * Create an AbilitiesApiProxy with all mocked tool dependencies. + * Stub instances for every declared tool, keyed by class name. * + * @var array + */ + private array $stubs = array(); + + /** + * Build a proxy over a real registry. + * + * @param bool $with_tools Whether to seed stub tools (only needed by register_tools()). * @return AbilitiesApiProxy */ - private function create_proxy(): AbilitiesApiProxy { - return new AbilitiesApiProxy( - \Mockery::mock( SiteInfo::class ), - \Mockery::mock( UpdateSiteOptions::class ), - \Mockery::mock( ActivatePlugin::class ), - \Mockery::mock( DeactivatePlugin::class ), - \Mockery::mock( GetPlugin::class ), - \Mockery::mock( ListPlugins::class ), - \Mockery::mock( UpdatePlugin::class ), - \Mockery::mock( ActivateTheme::class ), - \Mockery::mock( GetThemes::class ), - \Mockery::mock( SwitchTheme::class ), - \Mockery::mock( CreatePost::class ), - \Mockery::mock( UpdatePost::class ), - \Mockery::mock( DeletePost::class ), - \Mockery::mock( ListPosts::class ), - \Mockery::mock( GetPost::class ), - \Mockery::mock( GetPostByOptionName::class ), - \Mockery::mock( ListPostRevisions::class ), - \Mockery::mock( RestorePostRevision::class ), - \Mockery::mock( UpdatePostImageAltText::class ), - \Mockery::mock( CreatePageDraft::class ), - \Mockery::mock( PublishPageDraft::class ), - \Mockery::mock( DiscardPageDraft::class ), - \Mockery::mock( GetPageDraftStatus::class ), - \Mockery::mock( ListPageRevisions::class ), - \Mockery::mock( GetPageRevision::class ), - \Mockery::mock( DeletePageRevision::class ), - \Mockery::mock( DeleteMedia::class ), - \Mockery::mock( GetAllMedia::class ), - \Mockery::mock( GetMediaById::class ), - \Mockery::mock( ListMedia::class ), - \Mockery::mock( UpdateMediaMeta::class ), - \Mockery::mock( UploadImage::class ), - \Mockery::mock( ListTemplates::class ), - \Mockery::mock( UpdateTemplate::class ), - \Mockery::mock( DeleteTemplate::class ), - \Mockery::mock( ListTemplateParts::class ), - \Mockery::mock( UpdateTemplatePart::class ), - \Mockery::mock( DeleteTemplatePart::class ), - \Mockery::mock( ListTemplateRevisions::class ), - \Mockery::mock( ListTemplatePartRevisions::class ), - \Mockery::mock( GetGlobalStyles::class ), - \Mockery::mock( ListGlobalStyles::class ), - \Mockery::mock( ListGlobalStylesRevisions::class ), - \Mockery::mock( UpdateGlobalStyles::class ), - \Mockery::mock( GetBlockTypes::class ), - \Mockery::mock( GetBlockPatterns::class ), - \Mockery::mock( ListNavigations::class ), - \Mockery::mock( GetNavigation::class ), - \Mockery::mock( CreateNavigation::class ), - \Mockery::mock( UpdateNavigation::class ), - \Mockery::mock( DeleteNavigation::class ), - \Mockery::mock( ListNavigationRevisions::class ) - ); + private function create_proxy( bool $with_tools = false ): AbilitiesApiProxy { + $this->stubs = array(); + + if ( $with_tools ) { + foreach ( ToolRegistry::TOOLS as $tool_class ) { + $this->stubs[ $tool_class ] = \Mockery::mock( $tool_class ); + } + } + + $registry = new ToolRegistry( new Container( new RuntimeContainer( $this->stubs ) ) ); + + return new AbilitiesApiProxy( $registry ); } /** - * Setup() registers all three action hooks. - * - * Both wp_abilities_api_init (WP 6.9+ native) and abilities_api_init - * (bundled pre-6.9) are hooked — they are mutually exclusive at runtime, - * so register_tools() fires exactly once regardless of WP version. - * wp_abilities_api_categories_init is also registered for category support. + * Setup() hooks both Abilities API init variants plus the categories hook. */ public function test_setup_registers_all_hooks(): void { $proxy = $this->create_proxy(); @@ -170,170 +85,54 @@ public function test_register_categories_registers_all_categories(): void { } /** - * Register_tools() calls register() on each injected tool. + * Register_tools() calls register() exactly once on every tool in the registry. + * + * This is the assertion the old 52-parameter constructor was carrying by hand: a + * tool present in the registry but never registered would slip through otherwise. + */ + public function test_register_tools_calls_register_on_every_declared_tool(): void { + $proxy = $this->create_proxy( true ); + + foreach ( $this->stubs as $stub ) { + $stub->shouldReceive( 'register' )->once(); + } + + $proxy->register_tools(); + + $this->addToAssertionCount( 1 ); + } + + /** + * Tools are registered in the registry's declared order. */ - public function test_register_tools_calls_register_on_each_tool(): void { - $site_info = \Mockery::mock( SiteInfo::class ); - $update_site_opts = \Mockery::mock( UpdateSiteOptions::class ); - $activate_plugin = \Mockery::mock( ActivatePlugin::class ); - $deactivate_plugin = \Mockery::mock( DeactivatePlugin::class ); - $get_plugin = \Mockery::mock( GetPlugin::class ); - $list_plugins = \Mockery::mock( ListPlugins::class ); - $update_plugin = \Mockery::mock( UpdatePlugin::class ); - $activate_theme = \Mockery::mock( ActivateTheme::class ); - $get_themes = \Mockery::mock( GetThemes::class ); - $switch_theme = \Mockery::mock( SwitchTheme::class ); - $create_post = \Mockery::mock( CreatePost::class ); - $update_post = \Mockery::mock( UpdatePost::class ); - $delete_post = \Mockery::mock( DeletePost::class ); - $list_posts = \Mockery::mock( ListPosts::class ); - $get_post = \Mockery::mock( GetPost::class ); - $get_post_by_option_name = \Mockery::mock( GetPostByOptionName::class ); - $list_post_revisions = \Mockery::mock( ListPostRevisions::class ); - $restore_post_revision = \Mockery::mock( RestorePostRevision::class ); - $update_post_image_alt_text = \Mockery::mock( UpdatePostImageAltText::class ); - $create_page_draft = \Mockery::mock( CreatePageDraft::class ); - $publish_page_draft = \Mockery::mock( PublishPageDraft::class ); - $discard_page_draft = \Mockery::mock( DiscardPageDraft::class ); - $get_page_draft_status = \Mockery::mock( GetPageDraftStatus::class ); - $list_page_revisions = \Mockery::mock( ListPageRevisions::class ); - $get_page_revision = \Mockery::mock( GetPageRevision::class ); - $delete_page_revision = \Mockery::mock( DeletePageRevision::class ); - $delete_media = \Mockery::mock( DeleteMedia::class ); - $get_all_media = \Mockery::mock( GetAllMedia::class ); - $get_media_by_id = \Mockery::mock( GetMediaById::class ); - $list_media = \Mockery::mock( ListMedia::class ); - $update_media_meta = \Mockery::mock( UpdateMediaMeta::class ); - $upload_image = \Mockery::mock( UploadImage::class ); - $list_templates = \Mockery::mock( ListTemplates::class ); - $update_template = \Mockery::mock( UpdateTemplate::class ); - $delete_template = \Mockery::mock( DeleteTemplate::class ); - $list_template_parts = \Mockery::mock( ListTemplateParts::class ); - $update_template_part = \Mockery::mock( UpdateTemplatePart::class ); - $delete_template_part = \Mockery::mock( DeleteTemplatePart::class ); - $list_template_revisions = \Mockery::mock( ListTemplateRevisions::class ); - $list_template_part_revisions = \Mockery::mock( ListTemplatePartRevisions::class ); - $get_global_styles = \Mockery::mock( GetGlobalStyles::class ); - $list_global_styles = \Mockery::mock( ListGlobalStyles::class ); - $list_global_styles_revisions = \Mockery::mock( ListGlobalStylesRevisions::class ); - $update_global_styles = \Mockery::mock( UpdateGlobalStyles::class ); - $get_block_types = \Mockery::mock( GetBlockTypes::class ); - $get_block_patterns = \Mockery::mock( GetBlockPatterns::class ); - $list_navigations = \Mockery::mock( ListNavigations::class ); - $get_navigation = \Mockery::mock( GetNavigation::class ); - $create_navigation = \Mockery::mock( CreateNavigation::class ); - $update_navigation = \Mockery::mock( UpdateNavigation::class ); - $delete_navigation = \Mockery::mock( DeleteNavigation::class ); - $list_navigation_revisions = \Mockery::mock( ListNavigationRevisions::class ); + public function test_register_tools_follows_declaration_order(): void { + $proxy = $this->create_proxy( true ); + $registered = array(); + + foreach ( $this->stubs as $tool_class => $stub ) { + $stub->shouldReceive( 'register' )->once()->andReturnUsing( + function () use ( $tool_class, &$registered ) { + $registered[] = $tool_class; + } + ); + } - $site_info->shouldReceive( 'register' )->once(); - $update_site_opts->shouldReceive( 'register' )->once(); - $activate_plugin->shouldReceive( 'register' )->once(); - $deactivate_plugin->shouldReceive( 'register' )->once(); - $get_plugin->shouldReceive( 'register' )->once(); - $list_plugins->shouldReceive( 'register' )->once(); - $update_plugin->shouldReceive( 'register' )->once(); - $activate_theme->shouldReceive( 'register' )->once(); - $get_themes->shouldReceive( 'register' )->once(); - $switch_theme->shouldReceive( 'register' )->once(); - $create_post->shouldReceive( 'register' )->once(); - $update_post->shouldReceive( 'register' )->once(); - $delete_post->shouldReceive( 'register' )->once(); - $list_posts->shouldReceive( 'register' )->once(); - $get_post->shouldReceive( 'register' )->once(); - $get_post_by_option_name->shouldReceive( 'register' )->once(); - $list_post_revisions->shouldReceive( 'register' )->once(); - $restore_post_revision->shouldReceive( 'register' )->once(); - $update_post_image_alt_text->shouldReceive( 'register' )->once(); - $create_page_draft->shouldReceive( 'register' )->once(); - $publish_page_draft->shouldReceive( 'register' )->once(); - $discard_page_draft->shouldReceive( 'register' )->once(); - $get_page_draft_status->shouldReceive( 'register' )->once(); - $list_page_revisions->shouldReceive( 'register' )->once(); - $get_page_revision->shouldReceive( 'register' )->once(); - $delete_page_revision->shouldReceive( 'register' )->once(); - $delete_media->shouldReceive( 'register' )->once(); - $get_all_media->shouldReceive( 'register' )->once(); - $get_media_by_id->shouldReceive( 'register' )->once(); - $list_media->shouldReceive( 'register' )->once(); - $update_media_meta->shouldReceive( 'register' )->once(); - $upload_image->shouldReceive( 'register' )->once(); - $list_templates->shouldReceive( 'register' )->once(); - $update_template->shouldReceive( 'register' )->once(); - $delete_template->shouldReceive( 'register' )->once(); - $list_template_parts->shouldReceive( 'register' )->once(); - $update_template_part->shouldReceive( 'register' )->once(); - $delete_template_part->shouldReceive( 'register' )->once(); - $list_template_revisions->shouldReceive( 'register' )->once(); - $list_template_part_revisions->shouldReceive( 'register' )->once(); - $get_global_styles->shouldReceive( 'register' )->once(); - $list_global_styles->shouldReceive( 'register' )->once(); - $list_global_styles_revisions->shouldReceive( 'register' )->once(); - $update_global_styles->shouldReceive( 'register' )->once(); - $get_block_types->shouldReceive( 'register' )->once(); - $get_block_patterns->shouldReceive( 'register' )->once(); - $list_navigations->shouldReceive( 'register' )->once(); - $get_navigation->shouldReceive( 'register' )->once(); - $create_navigation->shouldReceive( 'register' )->once(); - $update_navigation->shouldReceive( 'register' )->once(); - $delete_navigation->shouldReceive( 'register' )->once(); - $list_navigation_revisions->shouldReceive( 'register' )->once(); + $proxy->register_tools(); - $proxy = new AbilitiesApiProxy( - $site_info, - $update_site_opts, - $activate_plugin, - $deactivate_plugin, - $get_plugin, - $list_plugins, - $update_plugin, - $activate_theme, - $get_themes, - $switch_theme, - $create_post, - $update_post, - $delete_post, - $list_posts, - $get_post, - $get_post_by_option_name, - $list_post_revisions, - $restore_post_revision, - $update_post_image_alt_text, - $create_page_draft, - $publish_page_draft, - $discard_page_draft, - $get_page_draft_status, - $list_page_revisions, - $get_page_revision, - $delete_page_revision, - $delete_media, - $get_all_media, - $get_media_by_id, - $list_media, - $update_media_meta, - $upload_image, - $list_templates, - $update_template, - $delete_template, - $list_template_parts, - $update_template_part, - $delete_template_part, - $list_template_revisions, - $list_template_part_revisions, - $get_global_styles, - $list_global_styles, - $list_global_styles_revisions, - $update_global_styles, - $get_block_types, - $get_block_patterns, - $list_navigations, - $get_navigation, - $create_navigation, - $update_navigation, - $delete_navigation, - $list_navigation_revisions - ); + $this->assertSame( ToolRegistry::TOOLS, $registered ); + } + + /** + * Firing both Abilities API hook variants must not register a tool twice. + */ + public function test_repeated_register_tools_reuses_the_same_instances(): void { + $proxy = $this->create_proxy( true ); + + foreach ( $this->stubs as $stub ) { + $stub->shouldReceive( 'register' )->twice(); + } + $proxy->register_tools(); $proxy->register_tools(); $this->addToAssertionCount( 1 ); diff --git a/tests/Unit/Mcp/Infrastructure/ToolRegistryTest.php b/tests/Unit/Mcp/Infrastructure/ToolRegistryTest.php new file mode 100644 index 0000000..3ec7fb4 --- /dev/null +++ b/tests/Unit/Mcp/Infrastructure/ToolRegistryTest.php @@ -0,0 +1,127 @@ +assertTrue( class_exists( $tool_class ), "missing class {$tool_class}" ); + } + } + + /** + * Every entry is a BaseTool, so register() is guaranteed to exist. + */ + public function test_every_declared_tool_extends_base_tool(): void { + foreach ( ToolRegistry::TOOLS as $tool_class ) { + $this->assertTrue( + is_subclass_of( $tool_class, BaseTool::class ), + "{$tool_class} does not extend BaseTool" + ); + } + } + + /** + * Every tool declares a TOOL_ID under the airo-wp namespace. + * + * A foreign prefix here would mean a tool was copied in without being retargeted. + */ + public function test_every_tool_id_is_namespaced_to_airo_wp(): void { + foreach ( ToolRegistry::TOOLS as $tool_class ) { + $this->assertTrue( defined( "{$tool_class}::TOOL_ID" ), "{$tool_class} has no TOOL_ID" ); + $this->assertStringStartsWith( 'airo-wp/', $tool_class::TOOL_ID, "{$tool_class} has a foreign TOOL_ID" ); + } + } + + /** + * No tool is listed twice, and no two tools share a TOOL_ID. + * + * A duplicate would register the same ability twice and shadow one of them. + */ + public function test_tool_classes_and_ids_are_unique(): void { + $classes = ToolRegistry::TOOLS; + $this->assertSame( array_values( array_unique( $classes ) ), array_values( $classes ), 'duplicate tool class' ); + + $ids = array(); + + foreach ( $classes as $tool_class ) { + $ids[] = $tool_class::TOOL_ID; + } + + $this->assertSame( array_values( array_unique( $ids ) ), $ids, 'duplicate TOOL_ID' ); + } + + /** + * tool_ids() returns one ID per declared tool, in declaration order, without + * instantiating anything. + */ + public function test_tool_ids_match_declaration_order(): void { + // Seeded with nothing: tool_ids() must not touch the container at all. + $registry = new ToolRegistry( new Container( new RuntimeContainer( array() ) ) ); + + $expected = array(); + + foreach ( ToolRegistry::TOOLS as $tool_class ) { + $expected[] = $tool_class::TOOL_ID; + } + + $this->assertSame( $expected, $registry->tool_ids() ); + } + + /** + * all() resolves one instance per declared tool, through the container. + */ + public function test_all_resolves_every_tool_through_the_container(): void { + $stubs = $this->tool_stubs(); + + $registry = new ToolRegistry( new Container( new RuntimeContainer( $stubs ) ) ); + + $this->assertSame( array_values( $stubs ), $registry->all() ); + } + + /** + * A stub instance per declared tool, keyed by class name. + * + * @return array + */ + private function tool_stubs(): array { + $stubs = array(); + + foreach ( ToolRegistry::TOOLS as $tool_class ) { + $stubs[ $tool_class ] = \Mockery::mock( $tool_class ); + } + + return $stubs; + } + + /** + * all() memoises, so repeated calls do not re-resolve. + * + * register_tools() may fire on more than one Abilities API hook variant. + */ + public function test_all_is_memoised(): void { + $registry = new ToolRegistry( new Container( new RuntimeContainer( $this->tool_stubs() ) ) ); + + $this->assertSame( $registry->all(), $registry->all() ); + } +} diff --git a/tests/Unit/Mcp/PackageTest.php b/tests/Unit/Mcp/PackageTest.php index 1464315..042ca34 100644 --- a/tests/Unit/Mcp/PackageTest.php +++ b/tests/Unit/Mcp/PackageTest.php @@ -11,8 +11,6 @@ use Brain\Monkey\Actions; use Brain\Monkey\Filters; -use GoDaddy\WordPress\Plugins\AiroWp\Container; -use GoDaddy\WordPress\Plugins\AiroWp\Internal\DependencyManagement\TestingContainer; use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Infrastructure\AbilitiesApiProxy; use GoDaddy\WordPress\Plugins\AiroWp\Mcp\Package; use GoDaddy\WordPress\Plugins\AiroWp\Tests\TestCase; @@ -29,9 +27,7 @@ public function test_init_calls_proxy_setup(): void { $mock_proxy = \Mockery::mock( AbilitiesApiProxy::class ); $mock_proxy->shouldReceive( 'setup' )->once(); - $testing_container = new TestingContainer( array() ); - $testing_container->replace( AbilitiesApiProxy::class, $mock_proxy ); - $container = new Container( $testing_container ); + $container = $this->make_container( array( AbilitiesApiProxy::class => $mock_proxy ) ); // McpAdapter::instance() internally calls add_action( 'rest_api_init', ... ). Actions\expectAdded( 'rest_api_init' )->atLeast()->once(); @@ -60,7 +56,7 @@ public function test_init_calls_proxy_setup(): void { * genuinely covered rather than merely absent. */ public function test_init_sets_up_auth_compatibility_layers(): void { - $container = new Container( new TestingContainer( array() ) ); + $container = $this->make_container(); // Only these filters are asserted. The adapter's own add_action calls are // singleton-guarded, so whether they fire depends on whether an earlier diff --git a/tests/Unit/Mcp/Tools/Media/UploadImageTest.php b/tests/Unit/Mcp/Tools/Media/UploadImageTest.php index 15e5d21..061f389 100644 --- a/tests/Unit/Mcp/Tools/Media/UploadImageTest.php +++ b/tests/Unit/Mcp/Tools/Media/UploadImageTest.php @@ -30,10 +30,77 @@ public function test_tool_id_has_correct_prefix(): void { $this->assertSame( 'airo-wp/upload-image', UploadImage::TOOL_ID ); } - public function test_missing_url_returns_error(): void { + public function test_missing_both_sources_returns_error(): void { $result = $this->tool->execute( array() ); $this->assertFalse( $result['success'] ); - $this->assertStringContainsString( 'URL is required', $result['message'] ); + $this->assertStringContainsString( 'Either url or file_data is required', $result['message'] ); + } + + public function test_both_sources_returns_error(): void { + $result = $this->tool->execute( + array( + 'url' => 'https://example.com/image.jpg', + 'file_data' => 'aGVsbG8=', + ) + ); + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'not both', $result['message'] ); + } + + public function test_input_schema_accepts_either_url_or_base64(): void { + $schema = $this->call_private( $this->tool, 'get_input_schema' ); + + $this->assertArrayHasKey( 'file_data', $schema['properties'], 'base64 upload path is missing from the schema' ); + $this->assertArrayHasKey( 'filename', $schema['properties'] ); + $this->assertArrayHasKey( 'mime_type', $schema['properties'] ); + + $this->assertArrayNotHasKey( 'required', $schema, 'a flat required list cannot express the url/file_data choice' ); + $this->assertSame( + array( + array( 'required' => array( 'url' ) ), + array( 'required' => array( 'file_data', 'filename' ) ), + ), + $schema['oneOf'] + ); + } + + public function test_base64_upload_requires_filename(): void { + $result = $this->tool->execute( array( 'file_data' => 'aGVsbG8=' ) ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'filename is required', $result['message'] ); + } + + public function test_base64_upload_rejects_undecodable_data(): void { + Functions\expect( 'absint' )->with( 5 )->andReturn( 5 ); + Functions\expect( 'get_post' )->with( 5 )->andReturn( (object) array( 'ID' => 5 ) ); + + $result = $this->tool->execute( + array( + 'file_data' => '!!!! not base64 !!!!', + 'filename' => 'logo.png', + 'post_id' => 5, + ) + ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'Invalid base64 data', $result['message'] ); + } + + public function test_base64_upload_rejects_disallowed_mime_type(): void { + Functions\when( 'sanitize_file_name' )->returnArg(); + Functions\expect( 'get_allowed_mime_types' )->andReturn( array( 'image/png' => 'image/png' ) ); + + $result = $this->tool->execute( + array( + 'file_data' => 'aGVsbG8=', + 'filename' => 'logo.svg', + 'mime_type' => 'image/svg+xml', + ) + ); + + $this->assertFalse( $result['success'] ); + $this->assertStringContainsString( 'Unsupported MIME type', $result['message'] ); } public function test_invalid_url_returns_error(): void { diff --git a/tests/Unit/Mcp/Tools/Navigation/CreateNavigationTest.php b/tests/Unit/Mcp/Tools/Navigation/CreateNavigationTest.php index ea898c4..39154f2 100644 --- a/tests/Unit/Mcp/Tools/Navigation/CreateNavigationTest.php +++ b/tests/Unit/Mcp/Tools/Navigation/CreateNavigationTest.php @@ -66,4 +66,18 @@ public function test_successful_creation_returns_navigation_data(): void { $this->assertArrayHasKey( 'navigation', $result ); $this->assertSame( 10, $result['navigation']['id'] ); } + + /** + * The navigation object in the output schema describes its own fields, so a + * client can tell what the response contains. + */ + public function test_output_schema_describes_navigation_fields(): void { + $schema = $this->call_private( $this->tool, 'get_output_schema' ); + $nav = $schema['properties']['navigation']; + + $this->assertArrayHasKey( 'properties', $nav, 'navigation was flattened to a bare object' ); + foreach ( array( 'id', 'date', 'date_gmt', 'guid', 'modified', 'modified_gmt', 'slug', 'status', 'type', 'link', 'title', 'content', 'template' ) as $field ) { + $this->assertArrayHasKey( $field, $nav['properties'], "missing {$field}" ); + } + } } diff --git a/tests/Unit/Mcp/Tools/Navigation/ListNavigationRevisionsTest.php b/tests/Unit/Mcp/Tools/Navigation/ListNavigationRevisionsTest.php index c9d247d..efb4eee 100644 --- a/tests/Unit/Mcp/Tools/Navigation/ListNavigationRevisionsTest.php +++ b/tests/Unit/Mcp/Tools/Navigation/ListNavigationRevisionsTest.php @@ -82,5 +82,17 @@ public function test_valid_parent_returns_empty_revisions(): void { $this->assertSame( array(), $result['revisions'] ); $this->assertSame( 0, $result['total'] ); } + /** + * The revisions array declares its item shape rather than being an untyped array. + */ + public function test_output_schema_describes_revision_items(): void { + $schema = $this->call_private( $this->tool, 'get_output_schema' ); + $revisions = $schema['properties']['revisions']; + + $this->assertArrayHasKey( 'items', $revisions, 'revisions lost its item schema' ); + foreach ( array( 'id', 'author', 'date', 'date_gmt', 'guid', 'modified', 'modified_gmt', 'parent', 'slug', 'title', 'content' ) as $field ) { + $this->assertArrayHasKey( $field, $revisions['items']['properties'], "missing {$field}" ); + } + } } } diff --git a/tests/Unit/Mcp/Tools/Navigation/UpdateNavigationTest.php b/tests/Unit/Mcp/Tools/Navigation/UpdateNavigationTest.php index 49258a2..620ab59 100644 --- a/tests/Unit/Mcp/Tools/Navigation/UpdateNavigationTest.php +++ b/tests/Unit/Mcp/Tools/Navigation/UpdateNavigationTest.php @@ -73,4 +73,18 @@ public function test_successful_update_returns_navigation_data(): void { $this->assertArrayHasKey( 'navigation', $result ); $this->assertSame( 42, $result['navigation']['id'] ); } + + /** + * The navigation object in the output schema describes its own fields, so a + * client can tell what the response contains. + */ + public function test_output_schema_describes_navigation_fields(): void { + $schema = $this->call_private( $this->tool, 'get_output_schema' ); + $nav = $schema['properties']['navigation']; + + $this->assertArrayHasKey( 'properties', $nav, 'navigation was flattened to a bare object' ); + foreach ( array( 'id', 'date', 'date_gmt', 'guid', 'modified', 'modified_gmt', 'slug', 'status', 'type', 'link', 'title', 'content', 'template' ) as $field ) { + $this->assertArrayHasKey( $field, $nav['properties'], "missing {$field}" ); + } + } } diff --git a/tests/Unit/Mcp/Tools/Pages/CreatePageDraftTest.php b/tests/Unit/Mcp/Tools/Pages/CreatePageDraftTest.php index 8896caa..5b416bf 100644 --- a/tests/Unit/Mcp/Tools/Pages/CreatePageDraftTest.php +++ b/tests/Unit/Mcp/Tools/Pages/CreatePageDraftTest.php @@ -101,6 +101,8 @@ function ( $id ) use ( $post, $draft ) { } ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); Functions\when( 'get_post_meta' )->alias( function ( $id, $key, $single ) { if ( 10 === (int) $id && DraftPageService::META_HAS_DRAFT === $key ) { @@ -150,6 +152,8 @@ function ( $content ) use ( &$kses_called ) { Functions\when( 'get_post' )->justReturn( $post ); Functions\when( 'get_post_meta' )->justReturn( '' ); // no draft exists + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); Functions\when( 'get_current_user_id' )->justReturn( 1 ); Functions\when( 'wp_slash' )->returnArg(); Functions\when( 'wp_insert_post' )->justReturn( new \WP_Error( 'test', 'stop here' ) ); diff --git a/tests/Unit/Mcp/Tools/Pages/DeletePageRevisionTest.php b/tests/Unit/Mcp/Tools/Pages/DeletePageRevisionTest.php index e85fdc5..58939a5 100644 --- a/tests/Unit/Mcp/Tools/Pages/DeletePageRevisionTest.php +++ b/tests/Unit/Mcp/Tools/Pages/DeletePageRevisionTest.php @@ -79,4 +79,16 @@ public function test_revision_not_belonging_to_parent_returns_error(): void { $this->assertFalse( $result['success'] ); $this->assertStringContainsString( 'does not belong to the specified parent page', $result['message'] ); } + + /** + * The deleted-revision object describes every field the tool actually returns. + */ + public function test_output_schema_describes_deleted_revision_fields(): void { + $schema = $this->call_private( $this->tool, 'get_output_schema' ); + $deleted = $schema['properties']['deleted']; + + foreach ( array( 'id', 'parent_id', 'author_id', 'date_created', 'title', 'content', 'slug' ) as $field ) { + $this->assertArrayHasKey( $field, $deleted['properties'], "missing {$field}" ); + } + } } diff --git a/tests/Unit/Mcp/Tools/Pages/PublishPageDraftTest.php b/tests/Unit/Mcp/Tools/Pages/PublishPageDraftTest.php index c11109f..366ba46 100644 --- a/tests/Unit/Mcp/Tools/Pages/PublishPageDraftTest.php +++ b/tests/Unit/Mcp/Tools/Pages/PublishPageDraftTest.php @@ -136,6 +136,9 @@ function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_i Functions\when( 'is_wp_error' )->justReturn( false ); Functions\when( 'delete_post_meta' )->justReturn( true ); Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'delete_post_thumbnail' )->justReturn( true ); Functions\when( 'do_action' )->justReturn( null ); Functions\when( 'apply_filters' )->returnArg( 2 ); @@ -207,6 +210,9 @@ function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_i Functions\when( 'is_wp_error' )->justReturn( false ); Functions\when( 'delete_post_meta' )->justReturn( true ); Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'delete_post_thumbnail' )->justReturn( true ); Functions\when( 'do_action' )->justReturn( null ); Functions\when( 'apply_filters' )->returnArg( 2 ); diff --git a/tests/Unit/Mcp/Tools/Plugins/ActivatePluginTest.php b/tests/Unit/Mcp/Tools/Plugins/ActivatePluginTest.php index 25c1758..ad7bf74 100644 --- a/tests/Unit/Mcp/Tools/Plugins/ActivatePluginTest.php +++ b/tests/Unit/Mcp/Tools/Plugins/ActivatePluginTest.php @@ -170,4 +170,19 @@ function ( $thing ) use ( $wp_error ) { $this->assertFalse( $result['success'] ); $this->assertSame( 'nonexistent-plugin', $result['plugin'] ); } + + /** + * plugin_slug carries minLength so an empty string is rejected at the + * schema boundary rather than only inside execute(). + */ + public function test_input_schema_constrains_plugin_slug_to_non_empty(): void { + $schema = $this->call_private( $this->tool, 'get_input_schema' ); + + $this->assertSame( + 1, + $schema['properties']['plugin_slug']['minLength'] ?? null, + 'plugin_slug lost its minLength constraint' + ); + $this->assertContains( 'plugin_slug', $schema['required'] ); + } } diff --git a/tests/Unit/Mcp/Tools/Plugins/DeactivatePluginTest.php b/tests/Unit/Mcp/Tools/Plugins/DeactivatePluginTest.php index 61dd59e..a086dcf 100644 --- a/tests/Unit/Mcp/Tools/Plugins/DeactivatePluginTest.php +++ b/tests/Unit/Mcp/Tools/Plugins/DeactivatePluginTest.php @@ -149,4 +149,19 @@ public function test_inactive_plugin_deactivation_succeeds(): void { $this->assertTrue( $result['success'] ); $this->assertStringContainsString( 'deactivated successfully', $result['message'] ); } + + /** + * plugin_slug carries minLength so an empty string is rejected at the + * schema boundary rather than only inside execute(). + */ + public function test_input_schema_constrains_plugin_slug_to_non_empty(): void { + $schema = $this->call_private( $this->tool, 'get_input_schema' ); + + $this->assertSame( + 1, + $schema['properties']['plugin_slug']['minLength'] ?? null, + 'plugin_slug lost its minLength constraint' + ); + $this->assertContains( 'plugin_slug', $schema['required'] ); + } } diff --git a/tests/Unit/Mcp/Tools/Plugins/UpdatePluginTest.php b/tests/Unit/Mcp/Tools/Plugins/UpdatePluginTest.php index 9081f90..8da1991 100644 --- a/tests/Unit/Mcp/Tools/Plugins/UpdatePluginTest.php +++ b/tests/Unit/Mcp/Tools/Plugins/UpdatePluginTest.php @@ -425,4 +425,19 @@ function ( $thing ) { $this->assertTrue( $result['success'] ); $this->assertStringContainsString( 'failed to reactivate', $result['message'] ); } + + /** + * plugin_slug carries minLength so an empty string is rejected at the + * schema boundary rather than only inside execute(). + */ + public function test_input_schema_constrains_plugin_slug_to_non_empty(): void { + $schema = $this->call_private( $this->tool, 'get_input_schema' ); + + $this->assertSame( + 1, + $schema['properties']['plugin_slug']['minLength'] ?? null, + 'plugin_slug lost its minLength constraint' + ); + $this->assertContains( 'plugin_slug', $schema['required'] ); + } } diff --git a/tests/Unit/Services/DraftPageServiceTest.php b/tests/Unit/Services/DraftPageServiceTest.php index 44a8ba6..11307ee 100644 --- a/tests/Unit/Services/DraftPageServiceTest.php +++ b/tests/Unit/Services/DraftPageServiceTest.php @@ -193,9 +193,6 @@ function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_i if ( $post_id === $draft_id && $key === DraftPageService::META_DRAFT_OF ) { return (string) $original_id; } - if ( $key === '_thumbnail_id' ) { - return ''; - } // replace_post_meta: get_post_meta( $source_id ) with no key returns array. if ( null === $key ) { return array(); @@ -210,6 +207,9 @@ function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_i Functions\when( 'is_wp_error' )->justReturn( false ); Functions\when( 'delete_post_meta' )->justReturn( true ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'delete_post_thumbnail' )->justReturn( true ); Functions\when( 'update_post_meta' )->justReturn( true ); Functions\when( 'do_action' )->justReturn( null ); Functions\when( 'apply_filters' )->returnArg( 2 ); @@ -256,9 +256,6 @@ function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_i if ( $post_id === $draft_id && $key === DraftPageService::META_DRAFT_OF ) { return (string) $original_id; } - if ( $key === '_thumbnail_id' ) { - return ''; - } if ( null === $key ) { return array(); } @@ -272,6 +269,9 @@ function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_i Functions\when( 'is_wp_error' )->justReturn( false ); Functions\when( 'delete_post_meta' )->justReturn( true ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'delete_post_thumbnail' )->justReturn( true ); Functions\when( 'update_post_meta' )->justReturn( true ); Functions\when( 'do_action' )->justReturn( null ); Functions\when( 'apply_filters' )->returnArg( 2 ); @@ -317,9 +317,6 @@ function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_i if ( $post_id === $draft_id && $key === DraftPageService::META_DRAFT_OF ) { return (string) $original_id; } - if ( $key === '_thumbnail_id' ) { - return ''; - } if ( null === $key ) { return array(); } @@ -333,6 +330,9 @@ function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_i Functions\when( 'is_wp_error' )->justReturn( false ); Functions\when( 'delete_post_meta' )->justReturn( true ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'delete_post_thumbnail' )->justReturn( true ); Functions\when( 'update_post_meta' )->justReturn( true ); Functions\when( 'apply_filters' )->returnArg( 2 ); @@ -366,6 +366,9 @@ public function test_discard_draft_trashes_draft_when_force_delete_false(): void ->andReturn( (string) $original_id ); Functions\when( 'delete_post_meta' )->justReturn( true ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'delete_post_thumbnail' )->justReturn( true ); Functions\expect( 'wp_trash_post' ) ->once() @@ -401,6 +404,9 @@ public function test_discard_draft_permanently_deletes_when_force_delete_true(): ->andReturn( (string) $original_id ); Functions\when( 'delete_post_meta' )->justReturn( true ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'delete_post_thumbnail' )->justReturn( true ); Functions\expect( 'wp_delete_post' ) ->once() @@ -435,6 +441,9 @@ public function test_discard_draft_returns_error_when_trash_fails(): void { ->andReturn( (string) $original_id ); Functions\when( 'delete_post_meta' )->justReturn( true ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'delete_post_thumbnail' )->justReturn( true ); Functions\expect( 'wp_trash_post' ) ->once() @@ -445,4 +454,90 @@ public function test_discard_draft_returns_error_when_trash_fails(): void { $this->assertInstanceOf( \WP_Error::class, $result ); $this->assertSame( 'trash_failed', $result->get_error_code() ); } + + /** + * A draft carrying a featured image transfers it to the original via the + * thumbnail API, not a raw _thumbnail_id write. + * + * set_post_thumbnail() drops the meta when the attachment no longer resolves to + * an image; update_post_meta() would happily copy a dangling attachment ID onto + * the live page. + */ + public function test_publish_draft_transfers_featured_image_via_the_thumbnail_api(): void { + $draft_id = 200; + $original_id = 100; + $thumbnail_id = 4242; + + $this->arrange_publish_draft( $draft_id, $original_id ); + + Functions\when( 'get_post_thumbnail_id' )->justReturn( $thumbnail_id ); + Functions\expect( 'set_post_thumbnail' )->once()->with( $original_id, $thumbnail_id )->andReturn( true ); + Functions\expect( 'delete_post_thumbnail' )->never(); + + $this->assertSame( true, $this->service->publish_draft( $draft_id ) ); + } + + /** + * A draft with no featured image clears the original's, through the API. + */ + public function test_publish_draft_clears_featured_image_when_draft_has_none(): void { + $draft_id = 200; + $original_id = 100; + + $this->arrange_publish_draft( $draft_id, $original_id ); + + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\expect( 'delete_post_thumbnail' )->once()->with( $original_id )->andReturn( true ); + Functions\expect( 'set_post_thumbnail' )->never(); + + $this->assertSame( true, $this->service->publish_draft( $draft_id ) ); + } + + /** + * Shared arrangement for a successful publish_draft, minus the thumbnail stubs + * the caller wants to assert on. + * + * @param int $draft_id Draft post ID. + * @param int $original_id Original post ID. + */ + private function arrange_publish_draft( int $draft_id, int $original_id ): void { + $draft = new \stdClass(); + $draft->ID = $draft_id; + $draft->post_type = 'page'; + $draft->post_status = 'draft'; + $draft->post_content = 'content'; + $draft->post_title = 'title'; + $draft->post_excerpt = ''; + $draft->menu_order = 0; + $draft->post_password = ''; + $draft->comment_status = 'open'; + $draft->ping_status = 'open'; + $draft->post_content_filtered = ''; + + $original = new \stdClass(); + $original->ID = $original_id; + $original->post_status = 'publish'; + + Functions\expect( 'get_post' )->twice()->andReturnValues( array( $draft, $original ) ); + + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key = null, $single = false ) use ( $draft_id, $original_id ) { + if ( $post_id === $draft_id && $key === DraftPageService::META_DRAFT_OF ) { + return (string) $original_id; + } + if ( null === $key ) { + return array(); + } + return ''; + } + ); + + Functions\expect( 'wp_update_post' )->once()->andReturn( $original_id ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'delete_post_meta' )->justReturn( true ); + Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'do_action' )->justReturn( null ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( 'wp_trash_post' )->justReturn( new \WP_Post() ); + } } diff --git a/tests/e2e/functional/specs/mcp-block-markup-roundtrip.spec.ts b/tests/e2e/functional/specs/mcp-block-markup-roundtrip.spec.ts new file mode 100644 index 0000000..0ef0548 --- /dev/null +++ b/tests/e2e/functional/specs/mcp-block-markup-roundtrip.spec.ts @@ -0,0 +1,197 @@ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Block markup must survive the tools that write post content. + * + * CreatePost, UpdatePost and CreatePageDraft all pass content through + * wp_kses_post(). That is a deliberate hardening the port added over upstream, but + * KSES rewrites HTML comments — it strips the delimiters, re-runs itself on the + * inner text, collapses runs of dashes and trims a trailing dash before rebuilding + * the comment. Gutenberg block delimiters *are* HTML comments, often carrying JSON + * attributes, so "content is sanitised" and "content is preserved" are not obviously + * compatible claims. + * + * CreatePageDraft is the case that matters most: when no new content is supplied it + * applies wp_kses_post() to the *existing* post_content. So merely drafting a page + * runs its stored blocks through KSES, and publishing that draft writes the result + * back over the original. + * + * The rest of the suite only ever sends plain-text content, so none of this was + * covered. These tests assert byte-exact round-trips. + */ + +const MCP_ENDPOINT = '/wp-json/airo-wp/v1/mcp/streamable'; + +// Deliberately exercises the parts of KSES that rewrite comments: JSON attributes +// with quotes, braces and colons; a self-closing block; nested blocks; and an +// entity. +const BLOCK_MARKUP = [ + '', + '

Pricing & plans

', + '', + '', + '
', + '', + '
', + '

Starter tier

', + '
', + '', + '
', + '', + '', + '', + '', +].join('\n'); + +async function callTool( + requestUtils: import('@wordpress/e2e-test-utils-playwright').RequestUtils, + sessionId: string, + toolName: string, + args: Record, + id = 2 +) { + return requestUtils.request.post(MCP_ENDPOINT, { + headers: { + 'Content-Type': 'application/json', + 'Mcp-Session-Id': sessionId, + 'X-WP-Nonce': requestUtils.storageState!.nonce, + }, + data: { + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: toolName, arguments: args }, + }, + }); +} + +function resultOf(body: { result?: { content?: Array<{ text: string }> } }) { + const content = body.result?.content ?? []; + expect(content.length).toBeGreaterThan(0); + return JSON.parse(content[0].text); +} + +test.describe('block markup survives the content-writing tools', () => { + let sessionId: string; + + test.beforeAll(async ({ requestUtils }) => { + const initResponse = await requestUtils.request.post(MCP_ENDPOINT, { + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': requestUtils.storageState!.nonce }, + data: { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'playwright-e2e', version: '1.0' }, + }, + }, + }); + + expect(initResponse.status(), `initialize failed: ${await initResponse.text()}`).toBe(200); + sessionId = initResponse.headers()['mcp-session-id']; + expect(sessionId, 'initialize did not return mcp-session-id header').toBeTruthy(); + }); + + test('create-post preserves block delimiters and their JSON attributes', async ({ requestUtils }) => { + const created = resultOf( + await (await callTool(requestUtils, sessionId, 'airo-wp-create-post', { + title: 'E2E block markup round-trip', + content: BLOCK_MARKUP, + status: 'draft', + }, 2)).json() + ); + + expect(created.success, `create failed: ${JSON.stringify(created)}`).toBe(true); + expect(created.post_id).toBeGreaterThan(0); + + const stored = await requestUtils.rest<{ content: { raw: string } }>({ + path: `/wp/v2/posts/${created.post_id}?context=edit`, + }); + + expect(stored.content.raw, 'block markup was altered on the way in').toBe(BLOCK_MARKUP); + }); + + test('update-post preserves block delimiters and their JSON attributes', async ({ requestUtils }) => { + const created = resultOf( + await (await callTool(requestUtils, sessionId, 'airo-wp-create-post', { + title: 'E2E block markup update target', + content: 'placeholder', + status: 'draft', + }, 3)).json() + ); + expect(created.success, `create failed: ${JSON.stringify(created)}`).toBe(true); + + const updated = resultOf( + await (await callTool(requestUtils, sessionId, 'airo-wp-update-post', { + post_id: created.post_id, + content: BLOCK_MARKUP, + }, 4)).json() + ); + expect(updated.success, `update failed: ${JSON.stringify(updated)}`).toBe(true); + + const stored = await requestUtils.rest<{ content: { raw: string } }>({ + path: `/wp/v2/posts/${created.post_id}?context=edit`, + }); + + expect(stored.content.raw, 'block markup was altered on update').toBe(BLOCK_MARKUP); + }); + + test('drafting a page does not rewrite the blocks already stored on it', async ({ requestUtils }) => { + // Seeded through the REST API rather than a tool, so the stored content is known + // to be untouched before create-page-draft copies it. + const page = await requestUtils.rest<{ id: number }>({ + method: 'POST', + path: '/wp/v2/pages', + data: { title: 'E2E draft block markup', content: BLOCK_MARKUP, status: 'publish' }, + }); + + const seeded = await requestUtils.rest<{ content: { raw: string } }>({ + path: `/wp/v2/pages/${page.id}?context=edit`, + }); + expect(seeded.content.raw, 'seed did not store the markup verbatim').toBe(BLOCK_MARKUP); + + // No `content` argument: the tool copies existing post_content through + // wp_kses_post(), which is the path under test. + const draft = resultOf( + await (await callTool(requestUtils, sessionId, 'airo-wp-create-page-draft', { + post_id: page.id, + }, 5)).json() + ); + expect(draft.success, `draft failed: ${JSON.stringify(draft)}`).toBe(true); + + const draftId = draft.draft_id ?? draft.draft_post_id ?? draft.post_id; + expect(draftId, `no draft id in response: ${JSON.stringify(draft)}`).toBeTruthy(); + + const stored = await requestUtils.rest<{ content: { raw: string } }>({ + path: `/wp/v2/pages/${draftId}?context=edit`, + }); + + expect(stored.content.raw, 'drafting a page altered its block markup').toBe(BLOCK_MARKUP); + }); + + test('sanitisation is still active: a script tag is stripped, blocks are not', async ({ requestUtils }) => { + // Pairs with the tests above. On its own, "markup came back unchanged" is also + // what you would see if wp_kses_post() were not being applied at all — so this + // asserts the filter is genuinely running by giving it something it must remove, + // in the same request as blocks it must keep. + const created = resultOf( + await (await callTool(requestUtils, sessionId, 'airo-wp-create-post', { + title: 'E2E kses is active', + content: `${BLOCK_MARKUP}\n`, + status: 'draft', + }, 6)).json() + ); + + expect(created.success, `create failed: ${JSON.stringify(created)}`).toBe(true); + + const stored = await requestUtils.rest<{ content: { raw: string } }>({ + path: `/wp/v2/posts/${created.post_id}?context=edit`, + }); + + expect(stored.content.raw, 'script tag survived — content is not being sanitised').not.toContain(''); + expect(stored.content.raw).toContain(''); + }); +}); diff --git a/tests/e2e/functional/specs/tools/mcp-activate-plugin-tool.spec.ts b/tests/e2e/functional/specs/tools/mcp-activate-plugin-tool.spec.ts index 0fc762f..24658d0 100644 --- a/tests/e2e/functional/specs/tools/mcp-activate-plugin-tool.spec.ts +++ b/tests/e2e/functional/specs/tools/mcp-activate-plugin-tool.spec.ts @@ -62,20 +62,21 @@ test.describe('airo-wp/activate-plugin tool', () => { expect(sessionId, 'initialize did not return mcp-session-id header').toBeTruthy(); }); - test('returns error when plugin slug is empty', async ({ requestUtils }) => { + test('rejects an empty plugin_slug at the schema boundary', async ({ requestUtils }) => { + // minLength on plugin_slug means an empty string never reaches execute(): the MCP + // layer rejects it during input validation and returns isError=true, rather than + // the tool returning its own success:false payload. const response = await callTool(requestUtils, sessionId, 'airo-wp-activate-plugin', { plugin_slug: '' }); expect(response.status(), `tools/call failed: ${await response.text()}`).toBe(200); const body = await response.json(); expect(body.error, `JSON-RPC error: ${JSON.stringify(body.error)}`).toBeUndefined(); - expect(body.result?.isError, 'tools/call result has isError=true').not.toBe(true); + expect(body.result?.isError, 'empty plugin_slug should fail input validation').toBe(true); const content: Array<{ type: string; text: string }> = body.result?.content ?? []; - const data = JSON.parse(content[0].text); - - expect(data.success, 'response should indicate failure').toBe(false); - expect(data.message.toLowerCase()).toContain('required'); + expect(content.length).toBeGreaterThan(0); + expect(content[0].text.toLowerCase()).toContain('invalid input'); }); test('activates an installed but inactive plugin', async ({ requestUtils }) => { diff --git a/tests/e2e/functional/specs/tools/mcp-update-plugin-tool.spec.ts b/tests/e2e/functional/specs/tools/mcp-update-plugin-tool.spec.ts index 359a48a..695191a 100644 --- a/tests/e2e/functional/specs/tools/mcp-update-plugin-tool.spec.ts +++ b/tests/e2e/functional/specs/tools/mcp-update-plugin-tool.spec.ts @@ -62,20 +62,21 @@ test.describe('airo-wp/update-plugin tool', () => { expect(sessionId, 'initialize did not return mcp-session-id header').toBeTruthy(); }); - test('returns error when plugin_slug is empty', async ({ requestUtils }) => { + test('rejects an empty plugin_slug at the schema boundary', async ({ requestUtils }) => { + // minLength on plugin_slug means an empty string never reaches execute(): the MCP + // layer rejects it during input validation and returns isError=true, rather than + // the tool returning its own success:false payload. const response = await callTool(requestUtils, sessionId, 'airo-wp-update-plugin', { plugin_slug: '' }); expect(response.status(), `tools/call failed: ${await response.text()}`).toBe(200); const body = await response.json(); expect(body.error, `JSON-RPC error: ${JSON.stringify(body.error)}`).toBeUndefined(); - expect(body.result?.isError, 'tools/call result has isError=true').not.toBe(true); + expect(body.result?.isError, 'empty plugin_slug should fail input validation').toBe(true); const content: Array<{ type: string; text: string }> = body.result?.content ?? []; - const data = JSON.parse(content[0].text); - - expect(data.success, 'response should indicate failure').toBe(false); - expect(data.message.toLowerCase()).toContain('required'); + expect(content.length).toBeGreaterThan(0); + expect(content[0].text.toLowerCase()).toContain('invalid input'); }); test('returns error when plugin is not installed', async ({ requestUtils }) => { diff --git a/tests/e2e/functional/specs/tools/mcp-upload-image-tool.spec.ts b/tests/e2e/functional/specs/tools/mcp-upload-image-tool.spec.ts index c863a3a..02e3694 100644 --- a/tests/e2e/functional/specs/tools/mcp-upload-image-tool.spec.ts +++ b/tests/e2e/functional/specs/tools/mcp-upload-image-tool.spec.ts @@ -116,7 +116,7 @@ test.describe('airo-wp/upload-image tool', () => { expect(data.message).toContain('999999'); }); - test('rejects missing required URL via schema validation', async ({ requestUtils }) => { + test('rejects a request carrying neither url nor file_data', async ({ requestUtils }) => { const response = await callTool(requestUtils, sessionId, 'airo-wp-upload-image', { title: 'Some Title', }, 5); @@ -126,9 +126,54 @@ test.describe('airo-wp/upload-image tool', () => { const body = await response.json(); expect(body.error, `JSON-RPC error: ${JSON.stringify(body.error)}`).toBeUndefined(); - // MCP schema validation rejects missing required 'url' with isError=true. - expect(body.result?.isError, 'should reject missing required url').toBe(true); + // The schema expresses the url/file_data choice with oneOf, so neither branch + // matching is reported as a format mismatch rather than a single missing field. + expect(body.result?.isError, 'should reject a request with no image source').toBe(true); const content: Array<{ type: string; text: string }> = body.result?.content ?? []; - expect(content[0].text.toLowerCase()).toContain('url'); + expect(content.length).toBeGreaterThan(0); + expect(content[0].text.toLowerCase()).toContain('does not match any of the expected formats'); + }); + + test('uploads an image from base64 file data', async ({ requestUtils }) => { + // 1x1 transparent PNG. + const PNG_1PX = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAI/AL+g0Cs0gAAAABJRU5ErkJggg=='; + + const response = await callTool(requestUtils, sessionId, 'airo-wp-upload-image', { + file_data: PNG_1PX, + filename: 'airo-wp-e2e-pixel.png', + mime_type: 'image/png', + title: 'Airo WP e2e pixel', + alt_text: 'a single transparent pixel', + }, 6); + + expect(response.status(), `tools/call failed: ${await response.text()}`).toBe(200); + + const body = await response.json(); + expect(body.error, `JSON-RPC error: ${JSON.stringify(body.error)}`).toBeUndefined(); + expect(body.result?.isError, `isError=true: ${JSON.stringify(body.result)}`).not.toBe(true); + + const content: Array<{ type: string; text: string }> = body.result?.content ?? []; + expect(content.length).toBeGreaterThan(0); + const data = JSON.parse(content[0].text); + + expect(data.success, `expected success: ${JSON.stringify(data)}`).toBe(true); + expect(data.attachment_id, 'no attachment_id returned').toBeTruthy(); + expect(data.url).toContain('airo-wp-e2e-pixel'); + }); + + test('rejects base64 file data with no filename', async ({ requestUtils }) => { + const response = await callTool(requestUtils, sessionId, 'airo-wp-upload-image', { + file_data: 'aGVsbG8=', + }, 7); + + expect(response.status(), `tools/call failed: ${await response.text()}`).toBe(200); + + const body = await response.json(); + expect(body.error, `JSON-RPC error: ${JSON.stringify(body.error)}`).toBeUndefined(); + + // oneOf requires file_data and filename together, so this fails validation + // before the tool's own filename check is reached. + expect(body.result?.isError, 'file_data without filename should be rejected').toBe(true); }); }); diff --git a/tests/scripts/plugin-check.mjs b/tests/scripts/plugin-check.mjs index f6ec036..1346930 100644 --- a/tests/scripts/plugin-check.mjs +++ b/tests/scripts/plugin-check.mjs @@ -8,12 +8,12 @@ * always tears down even on failure. */ -import { createHash } from 'node:crypto'; import { execSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import AdmZip from 'adm-zip'; +import { composeProjectNames } from './wp-env-lifecycle.mjs'; const ROOT = path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '../..' ); const ZIP_SRC = path.join( ROOT, 'builds', 'airo-wp.zip' ); @@ -75,9 +75,22 @@ for ( let attempt = 1; attempt <= 3; attempt++ ) { try { // Copy PCP early-init marker into the CLI container. - // wp-env stores compose files at ~/.wp-env// - const hash = createHash( 'md5' ).update( WP_ENV_JSON ).digest( 'hex' ); - const composeDir = path.join( WP_ENV_HOME, hash ); + // wp-env stores compose files under WP_ENV_HOME in a directory whose name it + // derives from the config path, and 11.0.0 changed how: the full md5 became a + // descriptive `wp-env--`, with the old spelling kept + // only where that directory already exists. Resolve whichever is really there + // rather than recomputing one, so this works on both a developer machine + // carrying a legacy directory and a clean CI runner that is not. + const composeDir = composeProjectNames( WP_ENV_DIR ) + .map( ( name ) => path.join( WP_ENV_HOME, name ) ) + .find( ( dir ) => existsSync( path.join( dir, 'docker-compose.yml' ) ) ); + + if ( ! composeDir ) { + console.error( + `✗ No wp-env compose directory found under ${ WP_ENV_HOME } for ${ WP_ENV_JSON }.` + ); + process.exit( 1 ); + } const containerId = execSync( 'docker compose ps -q cli', { cwd: composeDir, encoding: 'utf8' } diff --git a/tests/scripts/wp-env-lifecycle.mjs b/tests/scripts/wp-env-lifecycle.mjs index 2325bfa..5ae6847 100644 --- a/tests/scripts/wp-env-lifecycle.mjs +++ b/tests/scripts/wp-env-lifecycle.mjs @@ -22,25 +22,56 @@ import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { execSync, spawnSync } from 'node:child_process'; +import path from 'node:path'; // ─── internal helpers ────────────────────────────────────────────────────────── +/** + * The Docker Compose project names wp-env may be using for this config. + * + * wp-env names its containers after its work directory, and it derives that + * directory two different ways: historically the full md5 of the config file + * path, and since 11.0.0 a descriptive `wp-env--`. It + * keeps the legacy spelling only when that cache directory already exists, so a + * machine that has run an older wp-env stays on the old name while a clean one + * gets the new name. + * + * Both are accepted rather than tracking which applies. Checking only the full + * hash made this fail exactly where it is least visible: on a clean CI runner + * `wp-env start` succeeded and reported both sites up, then the check below found + * nothing and aborted the run — while every developer machine, having a legacy + * cache directory, kept working. + */ +export function composeProjectNames(root) { + const fullHash = createHash('md5').update(`${root}/.wp-env.json`).digest('hex'); + return [ + // wp-env 10.x, and 11.x where a legacy cache directory already exists. + fullHash, + // wp-env 11.x on a directory it has not seen before. + `wp-env-${path.basename(root).toLowerCase()}-${fullHash.slice(0, 8)}`, + ]; +} + function detectRunning(root) { - const hash = createHash('md5').update(`${root}/.wp-env.json`).digest('hex'); - try { - // Match the long-lived tests-wordpress service specifically, not any - // container carrying the env hash. The mysql services can stay Up while - // the WordPress containers have exited (e.g. OOM-killed): filtering on the - // bare hash would misread that half-up state as "running" and skip - // wp-env start, so commands then hit an exited tests-wordpress container. - const out = execSync( - `docker ps --filter "name=${hash}-tests-wordpress" --format "{{.Names}}"`, - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } - ); - return out.trim().length > 0; - } catch { - return false; + // Match the long-lived tests-wordpress service specifically, not any + // container carrying the env hash. The mysql services can stay Up while + // the WordPress containers have exited (e.g. OOM-killed): filtering on the + // bare hash would misread that half-up state as "running" and skip + // wp-env start, so commands then hit an exited tests-wordpress container. + for (const project of composeProjectNames(root)) { + try { + const out = execSync( + `docker ps --filter "name=${project}-tests-wordpress" --format "{{.Names}}"`, + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + if (out.trim().length > 0) { + return true; + } + } catch { + // Docker unavailable, or this spelling matched nothing: try the next. + } } + return false; } function startEnv(root) {