diff --git a/includes/Abilities/Content/Content.php b/includes/Abilities/Content/Content.php index 4f027f8e5..d022ab25f 100644 --- a/includes/Abilities/Content/Content.php +++ b/includes/Abilities/Content/Content.php @@ -1,6 +1,6 @@ + */ + private const EDITABLE_FIELDS = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. + 'title' => array( + 'column' => 'post_title', + 'feature' => 'title', + ), + 'excerpt' => array( + 'column' => 'post_excerpt', + 'feature' => 'excerpt', + ), + 'content' => array( + 'column' => 'post_content', + 'feature' => 'editor', + ), + ); + /** * Fields that expose edit-context post data. * @@ -155,21 +186,34 @@ public function register_category(): void { } /** - * Registers all content abilities. + * Registers the read-only content abilities. * * Must run on the `wp_abilities_api_init` hook. * + * Plugin: `core/edit-content` is deliberately not registered here. Writes are a + * separate consent surface, so the edit ability registers through its own gated + * class (`Gated\Edit_Content`), which hooks {@see self::register_edit_content()} + * via {@see self::init_edit()}. + * * @since 1.2.0 */ public function register(): void { $this->register_read_content(); + } - /* - * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_post_types(), format_post(), check_permission()): - * - * $this->register_manage_content(); - */ + /** + * Hooks the `core/edit-content` ability into the Abilities API. + * + * Plugin: this method has no equivalent in the core class. It is the write-side + * counterpart of {@see self::init()}, kept separate so write access is an + * individually gateable unit: sites can remove `Gated\Edit_Content` through the + * `wpai_gated_abilities` filter without affecting read access. + * + * @since x.x.x + */ + public function init_edit(): void { + add_action( 'wp_abilities_api_categories_init', array( $this, 'register_category' ), 11 ); + add_action( 'wp_abilities_api_init', array( $this, 'register_edit_content' ), 11 ); } /** @@ -224,6 +268,438 @@ private function register_read_content(): void { ); } + /** + * Registers the `core/edit-content` ability. + * + * Must run on the `wp_abilities_api_init` hook; hooked by {@see self::init_edit()}. + * + * Plugin: this ability has no equivalent in the core class yet. + * + * @since x.x.x + */ + public function register_edit_content(): void { + /* + * Plugin: unregister any core-provided copy before the exposure check below, so + * the plugin's version — including its no-exposed-post-types gating — always + * wins, rather than leaving a core copy active when nothing is exposed. + */ + if ( wp_has_ability( 'core/edit-content' ) ) { + wp_unregister_ability( 'core/edit-content' ); + } + + /* + * Post types must be registered with `show_in_abilities` before the ability is + * registered so they are included in its input schema. + */ + $post_types = array_keys( $this->get_exposed_post_types() ); + if ( empty( $post_types ) ) { + return; + } + + wp_register_ability( + 'core/edit-content', + array( + 'label' => __( 'Edit Content', 'ai' ), + 'description' => __( 'Edits a post the current user can edit by replacing exact text in its title, excerpt, or content. The old_content value is matched byte-for-byte against the stored raw field value, and the number of matches must equal expected_matches (default 1) or the edit is refused with the actual count. The edit is saved through the standard WordPress update flow: save filters may adjust the saved value, and a revision is created when the post type supports revisions. Returns the replacement count and post status rather than the full field value; use core/read-content to read the updated content.', 'ai' ), + 'category' => self::CATEGORY, + 'input_schema' => $this->get_edit_content_input_schema( $post_types ), + 'output_schema' => $this->get_edit_content_output_schema(), + 'execute_callback' => array( $this, 'execute_edit_content' ), + 'permission_callback' => array( $this, 'check_edit_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + // MCP treats any non-additive update as destructive; replacing text + // qualifies, even though the save is revisioned where supported. + 'destructive' => true, + // Repeating a call is not a no-op: it fails when the snippet is gone, + // or applies again when new_content still contains old_content. + 'idempotent' => false, + // MCP clients assume open-world (may reach external systems) when the + // hint is absent; this ability only writes to the local database. + 'open_world' => false, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback for the `core/edit-content` ability. + * + * This gate is the authoritative permission decision: it resolves the requested + * post and denies missing, unexposed, or mismatched posts, and posts the current + * user cannot edit, before execution. {@see self::execute_edit_content()} only + * re-checks the lookup itself. + * + * Plugin: supports the plugin-only `core/edit-content` ability; no core equivalent yet. + * + * @since x.x.x + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return bool True if the request may proceed, false otherwise. + */ + public function check_edit_permission( $input = array() ): bool { + $input = rest_sanitize_object( $input ); + + if ( ! is_user_logged_in() ) { + return false; + } + + $post = $this->resolve_editable_post( $input ); + + return $post instanceof WP_Post && current_user_can( 'edit_post', $post->ID ); + } + + /** + * Resolves the post targeted by a `core/edit-content` request. + * + * Shared by {@see self::check_edit_permission()} and {@see self::execute_edit_content()} + * so the gate and the executor cannot drift apart on what a request targets. The ID + * must parse as a positive integer before the lookup runs: unlike the read paths, a + * value coerced to 0 must never reach get_post(), whose global-post fallback would + * resolve a write against an unrelated post from the main loop. + * + * Plugin: supports the plugin-only `core/edit-content` ability; no core equivalent yet. + * + * @since x.x.x + * + * @param array $input The sanitized ability input. + * @return \WP_Post|null The exposed, type-matching post, or null when the lookup fails. + */ + private function resolve_editable_post( array $input ): ?WP_Post { + $post_id = $this->parse_filter_int( $input['id'] ?? null, 1 ); + if ( null === $post_id ) { + return null; + } + + $post = get_post( $post_id ); + + if ( ! $post instanceof WP_Post + || ! isset( $this->get_exposed_post_types()[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + ) { + return null; + } + + return $post; + } + + /** + * Executes the `core/edit-content` ability. + * + * {@see WP_Ability::execute()} always runs {@see self::check_edit_permission()} first, + * so this callback only re-validates the lookup itself: existence, exposure, and a + * matching post type. Every failure before the save leaves the post unchanged. + * + * The match check and the save are not atomic: the replacement is computed from the + * post as loaded for this request, and a concurrent save to the same field between + * the check and the write is overwritten. The exact-match requirement narrows that + * window to this request but does not eliminate it. The post-save re-read behind + * `exact_persistence` has the same property under a persistent object cache: a + * concurrent save landing between the write and the re-read is what gets reported. + * + * After a successful save this callback never returns an error: save filters (for + * example KSES for users without `unfiltered_html`) may alter the saved value, which + * is reported through the `exact_persistence` output flag instead, so callers do not + * retry an update that has already been committed. + * + * Saving runs the full post-update pipeline, so its standard side effects apply; + * notably, editing a never-published draft floats its post date to the save time, + * matching how core treats drafts saved in the editor ("publish immediately"). + * + * Plugin: supports the plugin-only `core/edit-content` ability; no core equivalent yet. + * + * @since x.x.x + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array|\WP_Error The edit result, or a WP_Error. + */ + public function execute_edit_content( $input = array() ) { + $input = rest_sanitize_object( $input ); + + $post = $this->resolve_editable_post( $input ); + if ( ! $post instanceof WP_Post ) { + return $this->not_found_error(); + } + + $field = isset( $input['field'] ) && is_string( $input['field'] ) ? $input['field'] : ''; + if ( ! isset( self::EDITABLE_FIELDS[ $field ] ) ) { + return new WP_Error( + 'content_invalid_field', + __( 'The field value must be one of: title, excerpt, content.', 'ai' ), + array( 'status' => 400 ) + ); + } + + $column = self::EDITABLE_FIELDS[ $field ]['column']; + $feature = self::EDITABLE_FIELDS[ $field ]['feature']; + + if ( ! post_type_supports( $post->post_type, $feature ) ) { + return new WP_Error( + 'content_field_not_supported', + __( 'The post type of the requested post does not support the requested field.', 'ai' ), + array( 'status' => 422 ) + ); + } + + $expected = 1; + if ( isset( $input['expected_matches'] ) ) { + $parsed = $this->parse_filter_int( $input['expected_matches'], 1 ); + if ( null === $parsed ) { + return new WP_Error( + 'content_invalid_filter', + __( 'The expected_matches value must be a positive integer.', 'ai' ), + array( 'status' => 400 ) + ); + } + $expected = $parsed; + } + + $old = isset( $input['old_content'] ) && is_string( $input['old_content'] ) ? $input['old_content'] : ''; + $new = isset( $input['new_content'] ) && is_string( $input['new_content'] ) ? $input['new_content'] : ''; + + $current = $post->$column; + if ( ! is_string( $current ) ) { + return new WP_Error( + 'content_not_text', + __( 'The stored value of the requested field is not text and cannot be edited.', 'ai' ), + array( 'status' => 422 ) + ); + } + + /* + * A guardrail against corrupting length-prefixed serialized PHP, not a general + * structured-data boundary: it only recognizes values that are serialized as a + * whole, not serialized fragments or other formats embedded in a larger string. + */ + if ( is_serialized( $current ) ) { + return new WP_Error( + 'content_serialized', + __( 'The stored value of the requested field contains serialized data and cannot be edited safely.', 'ai' ), + array( 'status' => 422 ) + ); + } + + if ( '' === $old ) { + return new WP_Error( + 'content_empty_old', + __( 'The old_content value must not be empty.', 'ai' ), + array( 'status' => 400 ) + ); + } + + if ( $old === $new ) { + return new WP_Error( + 'content_no_change', + __( 'The new_content value must be different from the old_content value.', 'ai' ), + array( 'status' => 400 ) + ); + } + + // Matches are counted and replaced non-overlapping, left to right. + $count = substr_count( $current, $old ); + if ( 0 === $count ) { + return new WP_Error( + 'content_no_match', + __( 'The old_content value was not found in the requested field. Values are matched byte-for-byte against the stored raw value; read the raw field with core/read-content and retry with an exact snippet.', 'ai' ), + array( 'status' => 422 ) + ); + } + + if ( $count !== $expected ) { + return new WP_Error( + 'content_match_count_mismatch', + sprintf( + /* translators: 1: number of matches found, 2: number of matches expected. */ + _n( + 'The old_content value matched %1$d time, but expected_matches is %2$d. Provide a longer snippet, or set expected_matches to the actual count.', + 'The old_content value matched %1$d times, but expected_matches is %2$d. Provide a longer snippet, or set expected_matches to the actual count.', + $count, + 'ai' + ), + $count, + $expected + ), + array( + 'status' => 422, + 'found' => $count, + 'expected' => $expected, + ) + ); + } + + // The uniqueness check above makes str_replace() replace exactly $count + // occurrences; both needle and replacement are treated literally. + $updated = str_replace( $old, $new, $current ); + + // wp_update_post() expects slashed data and unslashes it internally. + $result = wp_update_post( + wp_slash( + array( + 'ID' => $post->ID, + $column => $updated, + ) + ), + true + ); + + if ( is_wp_error( $result ) ) { + return new WP_Error( + 'content_update_failed', + sprintf( + /* translators: %s: the underlying update error message. */ + __( 'The post could not be updated: %s', 'ai' ), + $result->get_error_message() + ), + array( + 'status' => 500, + 'cause' => $result->get_error_code(), + ) + ); + } + + if ( 0 === $result ) { + return new WP_Error( + 'content_update_failed', + __( 'The post could not be updated.', 'ai' ), + array( 'status' => 500 ) + ); + } + + $saved_post = get_post( $post->ID ); + + /* + * The update has committed, so a committed write must never be reported as an + * error: callers would retry an edit that already applied. If the post cannot + * be re-read (e.g. a save_post hook removed it, or a cache eviction), report + * the edit with `exact_persistence` false so the caller re-reads before + * making further edits. + */ + if ( ! $saved_post instanceof WP_Post ) { + return array( + 'id' => (int) $post->ID, + 'post_type' => $post->post_type, + 'status' => $post->post_status, + 'field' => $field, + 'replaced' => $count, + 'modified_gmt' => '', + 'exact_persistence' => false, + ); + } + + return array( + 'id' => (int) $saved_post->ID, + 'post_type' => $saved_post->post_type, + 'status' => $saved_post->post_status, + 'field' => $field, + 'replaced' => $count, + 'modified_gmt' => $this->format_gmt_date( $saved_post, 'modified' ), + 'exact_persistence' => $saved_post->$column === $updated, + ); + } + + /** + * Builds the input schema for the `core/edit-content` ability. + * + * Plugin: supports the plugin-only `core/edit-content` ability; no core equivalent yet. + * + * @since x.x.x + * + * @param list $post_types Exposed post type names. + * @return array The input JSON Schema. + */ + private function get_edit_content_input_schema( array $post_types ): array { + return array( + 'type' => 'object', + 'required' => array( 'id', 'field', 'old_content', 'new_content' ), + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'The ID of the post to edit.', 'ai' ), + ), + 'field' => array( + 'type' => 'string', + 'enum' => array_keys( self::EDITABLE_FIELDS ), + 'description' => __( 'The post field to edit.', 'ai' ), + ), + 'old_content' => array( + 'type' => 'string', + 'minLength' => 1, + 'description' => __( 'The exact text to replace, matched byte-for-byte against the stored raw field value. The number of matches must equal expected_matches.', 'ai' ), + ), + 'new_content' => array( + 'type' => 'string', + 'description' => __( 'The replacement text, stored literally. May be empty to delete the matched text.', 'ai' ), + ), + 'expected_matches' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Optional. The exact number of times old_content must match; every match is replaced. Defaults to 1, requiring a unique match.', 'ai' ), + ), + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => __( 'Optional. Restrict the edit to this post type; the post is edited only if it matches.', 'ai' ), + ), + ), + ); + } + + /** + * Builds the output schema for the `core/edit-content` ability. + * + * The full field value is deliberately not returned: echoing large content back + * after every edit would defeat the purpose of a targeted patch. Callers can read + * the updated value through `core/read-content`. + * + * Plugin: supports the plugin-only `core/edit-content` ability; no core equivalent yet. + * + * @since x.x.x + * + * @return array The output JSON Schema. + */ + private function get_edit_content_output_schema(): array { + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'required' => array( 'id', 'post_type', 'status', 'field', 'replaced', 'modified_gmt', 'exact_persistence' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.', 'ai' ), + ), + 'post_type' => array( + 'type' => 'string', + 'description' => __( 'The post type.', 'ai' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status after the edit.', 'ai' ), + ), + 'field' => array( + 'type' => 'string', + 'description' => __( 'The edited post field.', 'ai' ), + ), + 'replaced' => array( + 'type' => 'integer', + 'description' => __( 'The number of occurrences that were replaced.', 'ai' ), + ), + 'modified_gmt' => array( + 'type' => 'string', + 'description' => __( 'The last modified date after the edit, in ISO 8601 format as GMT. Empty string when the date cannot be resolved.', 'ai' ), + ), + 'exact_persistence' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the stored field value was confirmed to exactly equal the computed replacement result. False when save filters modified the saved value, or when the saved value could not be re-read; read the post again before making further edits.', 'ai' ), + ), + ), + ); + } + /** * Permission callback for the `core/read-content` ability. * @@ -305,16 +781,22 @@ private function input_int( $value ): int { * values that are not integers so a filter whose value cannot be honored can fail * loudly instead of silently widening the query: `author => 0` drops the author * filter (matching every author) and `post_parent => 0` becomes a top-level query. - * Accepts native integers and unsigned integer strings, mirroring how the JSON - * Schema `integer` type and the query-string transport respectively deliver them. + * Accepts native integers, whole-number floats, and unsigned integer strings, + * mirroring how JSON decoding, the JSON Schema `integer` type (which validates + * whole floats such as `3.0`), and the query-string transport deliver them. * * @since 1.2.0 + * @since x.x.x Accepts whole-number floats, matching `rest_is_integer()`. * * @param mixed $value The raw input value. * @param int $min The smallest acceptable value. * @return int|null The parsed integer, or null when the value is not an integer >= $min. */ private function parse_filter_int( $value, int $min ): ?int { + if ( is_float( $value ) && floor( $value ) === $value && $value >= PHP_INT_MIN && $value <= PHP_INT_MAX ) { + $value = (int) $value; + } + if ( is_int( $value ) ) { return $value >= $min ? $value : null; } diff --git a/includes/Abilities/Gated/Edit_Content.php b/includes/Abilities/Gated/Edit_Content.php new file mode 100644 index 000000000..5f39fde09 --- /dev/null +++ b/includes/Abilities/Gated/Edit_Content.php @@ -0,0 +1,41 @@ +init_edit(); + } +} diff --git a/includes/Abilities/Gated/Gated_Abilities.php b/includes/Abilities/Gated/Gated_Abilities.php index 6d2e6f21a..89c2cd0e2 100644 --- a/includes/Abilities/Gated/Gated_Abilities.php +++ b/includes/Abilities/Gated/Gated_Abilities.php @@ -38,6 +38,7 @@ final class Gated_Abilities { Read_Settings::class, Read_Users::class, Read_Content::class, + Edit_Content::class, ); /** diff --git a/tests/Integration/Includes/Abilities/Content/EditContentTest.php b/tests/Integration/Includes/Abilities/Content/EditContentTest.php new file mode 100644 index 000000000..ce82ef335 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Content/EditContentTest.php @@ -0,0 +1,806 @@ + + */ + private static $user_ids = array(); + + /** + * Creates shared users for the edit content ability tests. + * + * Posts are created per test instead: the ability mutates them, so shared post + * fixtures would leak state between tests. + * + * @since x.x.x + * + * @param \WP_UnitTest_Factory $factory The unit test factory. + */ + public static function wpSetUpBeforeClass( $factory ): void { + self::$user_ids = array( + 'administrator' => $factory->user->create( array( 'role' => 'administrator' ) ), + 'editor' => $factory->user->create( array( 'role' => 'editor' ) ), + 'subscriber' => $factory->user->create( array( 'role' => 'subscriber' ) ), + ); + } + + /** + * Set up test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + // Mark the curated core post types (post, page) as exposed to abilities. + ( new Show_In_Abilities() )->register(); + + $this->ensure_ability_category( 'content' ); + + /* + * The plugin registers its other abilities on the same abilities-init hook, so + * booting the registry here also registers `core/read-settings` (the `site` + * category) and `core/read-users` (the `user` category). Make sure those + * categories exist too; otherwise their registration emits an "incorrect usage" + * notice that fails these tests. + */ + $this->ensure_ability_category( 'site' ); + $this->ensure_ability_category( 'user' ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + // Content::register() registers both content abilities; unregister both. + foreach ( array( 'core/edit-content', 'core/read-content' ) as $ability ) { + if ( ! wp_has_ability( $ability ) ) { + continue; + } + + wp_unregister_ability( $ability ); + } + + // Restore the curated post types to their unmarked state to avoid leaking into other tests. + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + if ( ! $object ) { + continue; + } + + unset( $object->show_in_abilities ); + } + + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Ensures an ability category exists for an ability to attach to. + * + * @since x.x.x + * + * @param string $slug The ability category slug. + */ + private function ensure_ability_category( string $slug ): void { + if ( wp_has_ability_category( $slug ) ) { + return; + } + + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability_category( + $slug, + array( + 'label' => ucfirst( $slug ), + 'description' => ucfirst( $slug ) . '.', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Registers the plugin's content abilities inside a faked init action. + * + * Registers both the read and the edit ability: the edit ability is gated + * separately and is not part of {@see Content::register()}. + * + * @since x.x.x + */ + private function register_ability(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + $content = new Content(); + $content->register(); + $content->register_edit_content(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Logs in as a user with the given role and returns the user ID. + * + * @param string $role The role to log in as. + * @return int The user ID. + */ + private function login_as( string $role ): int { + $user_id = self::$user_ids[ $role ] ?? self::factory()->user->create( array( 'role' => $role ) ); + wp_set_current_user( $user_id ); + return $user_id; + } + + /** + * Executes the edit ability with the given input. + * + * @param array $input The ability input. + * @return mixed The ability result. + */ + private function execute_edit( array $input ) { + return wp_get_ability( 'core/edit-content' )->execute( $input ); + } + + /** + * The edit ability registers next to the read ability with write-oriented annotations. + * + * @since x.x.x + */ + public function test_registers_core_edit_content_ability(): void { + $this->register_ability(); + + $this->assertTrue( wp_has_ability( 'core/read-content' ), 'The read ability should still be registered alongside the edit ability.' ); + + $ability = wp_get_ability( 'core/edit-content' ); + + $this->assertNotNull( $ability, 'The core/edit-content ability should be registered.' ); + $this->assertSame( 'content', $ability->get_category(), 'The registered ability should use the content category.' ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'The ability should be exposed in REST.' ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertFalse( $annotations['readonly'], 'The ability should be marked as a write.' ); + $this->assertTrue( $annotations['destructive'], 'Replacing text is not additive, so the ability should be marked destructive.' ); + $this->assertFalse( $annotations['idempotent'], 'Repeating a call is not guaranteed to be a no-op.' ); + $this->assertFalse( $annotations['open_world'], 'The ability should be marked closed-world; it only writes the local database.' ); + + $schema = $ability->get_input_schema(); + $this->assertSame( array( 'id', 'field', 'old_content', 'new_content' ), $schema['required'], 'The input schema should require the locator, field, and both texts.' ); + $this->assertFalse( $schema['additionalProperties'], 'The input schema should reject unrelated properties.' ); + $this->assertSame( 1, $schema['properties']['expected_matches']['minimum'], 'The expected matches option should require a positive count.' ); + $this->assertArrayNotHasKey( 'default', $schema['properties']['expected_matches'], 'Expected matches should rely on runtime defaults, not schema defaults.' ); + + $output = $ability->get_output_schema(); + $this->assertArrayNotHasKey( 'content_raw', $output['properties'], 'The output should not echo the full field value back.' ); + $this->assertContains( 'replaced', $output['required'], 'The output should always report the replacement count.' ); + $this->assertContains( 'exact_persistence', $output['required'], 'The output should always report whether the value persisted exactly.' ); + } + + /** + * Read registration alone does not register the write ability. + * + * The write ability is a separate consent surface: it registers only through its + * own gated class, so booting the read path must not expose writes. + * + * @since x.x.x + */ + public function test_read_registration_does_not_register_edit_ability(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + ( new Content() )->register(); + } finally { + array_pop( $wp_current_filter ); + } + + $this->assertTrue( wp_has_ability( 'core/read-content' ), 'Read registration should register the read ability.' ); + $this->assertFalse( wp_has_ability( 'core/edit-content' ), 'Read registration should not register the write ability.' ); + } + + /** + * The edit ability is not registered when no post types are exposed to it. + * + * @since x.x.x + */ + public function test_does_not_register_core_edit_content_ability_without_exposed_post_types(): void { + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + $this->assertNotFalse( $object, "Precondition: the {$post_type} post type should exist." ); + + $object->show_in_abilities = false; + } + + $this->register_ability(); + + $this->assertFalse( wp_has_ability( 'core/edit-content' ), 'The edit ability should not register without any exposed post types.' ); + } + + /** + * A unique snippet in the content is replaced, revisioned, and reported compactly. + * + * @since x.x.x + */ + public function test_replaces_unique_snippet_in_content(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'The quick brown fox jumps over the lazy dog.', + ) + ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'brown fox', + 'new_content' => 'red fox', + ) + ); + + $this->assertIsArray( $result, 'A unique match should be replaced successfully.' ); + $this->assertSame( $post_id, $result['id'], 'The result should identify the edited post.' ); + $this->assertSame( 'post', $result['post_type'], 'The result should report the post type.' ); + $this->assertSame( 'publish', $result['status'], 'The result should report the post status after the edit.' ); + $this->assertSame( 'content', $result['field'], 'The result should report the edited field.' ); + $this->assertSame( 1, $result['replaced'], 'Exactly one occurrence should be replaced.' ); + $this->assertTrue( $result['exact_persistence'], 'The replacement should persist exactly for an administrator.' ); + $this->assertNotSame( '', $result['modified_gmt'], 'The result should report the modified date.' ); + $this->assertArrayNotHasKey( 'content_raw', $result, 'The result should not echo the full field value back.' ); + + $this->assertSame( + 'The quick red fox jumps over the lazy dog.', + get_post( $post_id )->post_content, + 'The stored content should contain the replacement.' + ); + + $revisions = wp_get_post_revisions( $post_id ); + $this->assertNotEmpty( $revisions, 'The edit should create a revision.' ); + $this->assertSame( + 'The quick red fox jumps over the lazy dog.', + array_shift( $revisions )->post_content, + 'The newest revision should hold the edited content.' + ); + } + + /** + * A unique snippet in the title is replaced. + * + * @since x.x.x + */ + public function test_replaces_unique_snippet_in_title(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_title' => 'Hello World Sample', + ) + ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'title', + 'old_content' => 'World', + 'new_content' => 'Universe', + ) + ); + + $this->assertIsArray( $result, 'A unique title match should be replaced successfully.' ); + $this->assertSame( 1, $result['replaced'], 'Exactly one occurrence should be replaced.' ); + $this->assertSame( 'Hello Universe Sample', get_post( $post_id )->post_title, 'The stored title should contain the replacement.' ); + } + + /** + * A unique snippet in the excerpt is replaced. + * + * @since x.x.x + */ + public function test_replaces_unique_snippet_in_excerpt(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_excerpt' => 'A short summary of the article.', + ) + ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'excerpt', + 'old_content' => 'short summary', + 'new_content' => 'brief overview', + ) + ); + + $this->assertIsArray( $result, 'A unique excerpt match should be replaced successfully.' ); + $this->assertSame( 1, $result['replaced'], 'Exactly one occurrence should be replaced.' ); + $this->assertSame( 'A brief overview of the article.', get_post( $post_id )->post_excerpt, 'The stored excerpt should contain the replacement.' ); + } + + /** + * A snippet that does not occur in the field is refused and the post is unchanged. + * + * @since x.x.x + */ + public function test_no_match_is_refused_and_post_unchanged(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Nothing to see here.', + ) + ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'unicorns', + 'new_content' => 'horses', + ) + ); + + $this->assertWPError( $result, 'A snippet with zero occurrences should be refused.' ); + $this->assertSame( 'content_no_match', $result->get_error_code(), 'The refusal should use the no-match error code.' ); + $this->assertSame( 'Nothing to see here.', get_post( $post_id )->post_content, 'The stored content should be unchanged.' ); + } + + /** + * Multiple matches with the default expected count of one are refused with the counts. + * + * @since x.x.x + */ + public function test_multiple_matches_with_default_expectation_are_refused(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'foo alpha foo beta foo', + ) + ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'foo', + 'new_content' => 'bar', + ) + ); + + $this->assertWPError( $result, 'An ambiguous snippet should be refused.' ); + $this->assertSame( 'content_match_count_mismatch', $result->get_error_code(), 'The refusal should use the count-mismatch error code.' ); + $this->assertStringContainsString( '3', $result->get_error_message(), 'The error message should report the actual match count.' ); + + $data = $result->get_error_data(); + $this->assertSame( 3, $data['found'], 'The error data should report the actual match count.' ); + $this->assertSame( 1, $data['expected'], 'The error data should report the expected match count.' ); + $this->assertSame( 'foo alpha foo beta foo', get_post( $post_id )->post_content, 'The stored content should be unchanged.' ); + } + + /** + * A matching expected count replaces every occurrence. + * + * @since x.x.x + */ + public function test_expected_matches_replaces_every_occurrence(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'foo alpha foo beta foo', + ) + ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'foo', + 'new_content' => 'bar', + 'expected_matches' => 3, + ) + ); + + $this->assertIsArray( $result, 'A matching expected count should allow the replacement.' ); + $this->assertSame( 3, $result['replaced'], 'Every occurrence should be replaced.' ); + $this->assertSame( 'bar alpha bar beta bar', get_post( $post_id )->post_content, 'The stored content should contain every replacement.' ); + } + + /** + * An expected count that does not equal the actual count is refused. + * + * @since x.x.x + */ + public function test_wrong_expected_matches_is_refused(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'foo alpha foo beta foo', + ) + ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'foo', + 'new_content' => 'bar', + 'expected_matches' => 2, + ) + ); + + $this->assertWPError( $result, 'A stale expected count should be refused.' ); + $this->assertSame( 'content_match_count_mismatch', $result->get_error_code(), 'The refusal should use the count-mismatch error code.' ); + $this->assertSame( 'foo alpha foo beta foo', get_post( $post_id )->post_content, 'The stored content should be unchanged.' ); + } + + /** + * A role without edit access to the post is denied and the post is unchanged. + * + * @since x.x.x + */ + public function test_subscriber_without_edit_cap_is_denied(): void { + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_ids['administrator'], + 'post_status' => 'publish', + 'post_content' => 'Public body text.', + ) + ); + + $this->login_as( 'subscriber' ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'Public', + 'new_content' => 'Hacked', + ) + ); + + $this->assertWPError( $result, 'A user without edit access should be denied.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'The denial should fail closed as a permission error.' ); + $this->assertSame( 'Public body text.', get_post( $post_id )->post_content, 'The stored content should be unchanged.' ); + } + + /** + * A post from a post type not exposed to abilities is denied. + * + * @since x.x.x + */ + public function test_unexposed_post_type_is_denied(): void { + register_post_type( + 'wpai_hidden_cpt', + array( + 'public' => true, + 'supports' => array( 'title', 'editor' ), + ) + ); + + try { + $this->login_as( 'administrator' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_hidden_cpt', + 'post_status' => 'publish', + 'post_content' => 'Hidden body text.', + ) + ); + + $this->register_ability(); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'Hidden', + 'new_content' => 'Visible', + ) + ); + + $this->assertWPError( $result, 'A post of an unexposed post type should be denied.' ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'The denial should fail closed as a permission error.' ); + $this->assertSame( 'Hidden body text.', get_post( $post_id )->post_content, 'The stored content should be unchanged.' ); + } finally { + unregister_post_type( 'wpai_hidden_cpt' ); + } + } + + /** + * Editing a field the post type does not support is refused. + * + * @since x.x.x + */ + public function test_unsupported_field_is_refused(): void { + register_post_type( + 'wpai_title_cpt', + array( + 'public' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title' ), + ) + ); + + try { + $this->login_as( 'administrator' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wpai_title_cpt', + 'post_status' => 'publish', + 'post_content' => 'Unsupported body text.', + ) + ); + + $this->register_ability(); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'Unsupported', + 'new_content' => 'Supported', + ) + ); + + $this->assertWPError( $result, 'Editing a field without post type support should be refused.' ); + $this->assertSame( 'content_field_not_supported', $result->get_error_code(), 'The refusal should use the unsupported-field error code.' ); + $this->assertSame( 'Unsupported body text.', get_post( $post_id )->post_content, 'The stored content should be unchanged.' ); + } finally { + unregister_post_type( 'wpai_title_cpt' ); + } + } + + /** + * A field whose entire stored value is serialized data is refused. + * + * @since x.x.x + */ + public function test_serialized_value_is_refused(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $serialized = 'a:1:{i:0;s:5:"hello";}'; + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => $serialized, + ) + ); + $this->assertSame( $serialized, get_post( $post_id )->post_content, 'Precondition: the serialized value should be stored verbatim.' ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'hello', + 'new_content' => 'world', + ) + ); + + $this->assertWPError( $result, 'A serialized stored value should be refused.' ); + $this->assertSame( 'content_serialized', $result->get_error_code(), 'The refusal should use the serialized error code.' ); + $this->assertSame( $serialized, get_post( $post_id )->post_content, 'The stored content should be unchanged.' ); + } + + /** + * Dollar signs and backslashes in the replacement are stored literally. + * + * @since x.x.x + */ + public function test_dollar_and_backslash_in_new_content_are_stored_literally(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'The price is PLACEHOLDER today.', + ) + ); + + $new = 'exactly $1.50 (group $0, path C:\temp, double \\ backslash)'; + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'PLACEHOLDER', + 'new_content' => $new, + ) + ); + + $this->assertIsArray( $result, 'A replacement containing $ and \\ should succeed.' ); + $this->assertTrue( $result['exact_persistence'], 'The literal characters should persist exactly.' ); + $this->assertSame( + "The price is {$new} today.", + get_post( $post_id )->post_content, + 'Dollar signs and backslashes in the replacement should be stored byte-for-byte.' + ); + } + + /** + * Dollar signs and backslashes in the snippet are matched literally, not as patterns. + * + * @since x.x.x + */ + public function test_dollar_and_backslash_in_old_content_are_matched_literally(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $raw = 'Total is $42.00 in the C:\data folder.'; + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => wp_slash( $raw ), + ) + ); + $this->assertSame( $raw, get_post( $post_id )->post_content, 'Precondition: the backslash content should be stored verbatim.' ); + + // A regex would match "Total" via "T.tal"; literal matching must not. + $probe = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'T.tal is', + 'new_content' => 'Sum is', + ) + ); + + $this->assertWPError( $probe, 'Regex metacharacters in the snippet should not act as patterns.' ); + $this->assertSame( 'content_no_match', $probe->get_error_code(), 'A pattern-style snippet should simply not match.' ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => '$42.00 in the C:\data', + 'new_content' => '$99.00 in the D:\backup', + ) + ); + + $this->assertIsArray( $result, 'A snippet containing $ and \\ should match literally.' ); + $this->assertSame( + 'Total is $99.00 in the D:\backup folder.', + get_post( $post_id )->post_content, + 'The literal snippet should be replaced byte-for-byte.' + ); + } + + /** + * An unparseable post ID fails closed instead of resolving against the global post. + * + * The public callbacks can be invoked directly without schema validation, and + * get_post( 0 ) falls back to the global post, so a coerced ID must never reach it. + * + * @since x.x.x + */ + public function test_invalid_id_fails_closed_against_global_post(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Global loop post body.', + ) + ); + + $previous_post = $GLOBALS['post'] ?? null; + $GLOBALS['post'] = get_post( $post_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulating a main-loop context for the fail-closed check. + + try { + $content = new Content(); + + $this->assertFalse( + $content->check_edit_permission( + array( + 'id' => 'abc', + 'field' => 'content', + 'old_content' => 'Global', + 'new_content' => 'Hacked', + ) + ), + 'An unparseable ID should be denied, not resolved via the global post.' + ); + + $result = $content->execute_edit_content( + array( + 'id' => 'abc', + 'field' => 'content', + 'old_content' => 'Global', + 'new_content' => 'Hacked', + ) + ); + + $this->assertWPError( $result, 'An unparseable ID should fail the lookup.' ); + $this->assertSame( 'content_not_found', $result->get_error_code(), 'The lookup failure should be a structural not-found error.' ); + $this->assertSame( 'Global loop post body.', get_post( $post_id )->post_content, 'The global post should be unchanged.' ); + } finally { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the previous global post context. + $GLOBALS['post'] = $previous_post; + } + } + + /** + * A whole-number float expected count is accepted, matching schema validation. + * + * JSON decoding can deliver an integer as a float (e.g. `3.0`), which the schema's + * integer type accepts, so the runtime must accept it too. + * + * @since x.x.x + */ + public function test_expected_matches_accepts_whole_number_float(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'foo alpha foo', + ) + ); + + $result = $this->execute_edit( + array( + 'id' => $post_id, + 'field' => 'content', + 'old_content' => 'foo', + 'new_content' => 'bar', + 'expected_matches' => 2.0, + ) + ); + + $this->assertIsArray( $result, 'A whole-number float expected count should be accepted.' ); + $this->assertSame( 2, $result['replaced'], 'Both occurrences should be replaced.' ); + $this->assertSame( 'bar alpha bar', get_post( $post_id )->post_content, 'The stored content should contain both replacements.' ); + } +} diff --git a/tests/Integration/Includes/Abilities/Gated/Gated_AbilitiesTest.php b/tests/Integration/Includes/Abilities/Gated/Gated_AbilitiesTest.php index c74c5c583..f1560f4d9 100644 --- a/tests/Integration/Includes/Abilities/Gated/Gated_AbilitiesTest.php +++ b/tests/Integration/Includes/Abilities/Gated/Gated_AbilitiesTest.php @@ -9,6 +9,7 @@ use RuntimeException; use WP_UnitTestCase; +use WordPress\AI\Abilities\Gated\Edit_Content; use WordPress\AI\Abilities\Gated\Gated_Abilities; use WordPress\AI\Abilities\Gated\Post_Utilities; use WordPress\AI\Abilities\Gated\Read_Content; @@ -65,7 +66,7 @@ class Gated_AbilitiesTest extends WP_UnitTestCase { public function test_get_all_returns_default_gated_abilities(): void { $abilities = Gated_Abilities::get_all(); - $this->assertCount( 4, $abilities ); + $this->assertCount( 5, $abilities ); foreach ( $abilities as $ability ) { $this->assertInstanceOf( Abstract_Gated_Ability::class, $ability ); @@ -76,6 +77,7 @@ public function test_get_all_returns_default_gated_abilities(): void { $this->assertContains( Read_Settings::class, $classes ); $this->assertContains( Read_Users::class, $classes ); $this->assertContains( Read_Content::class, $classes ); + $this->assertContains( Edit_Content::class, $classes ); } /** @@ -91,6 +93,7 @@ public function test_gated_abilities_report_expected_core_object_exposure(): voi $this->assertTrue( $exposure[ Read_Settings::class ], 'read-settings depends on core-object exposure.' ); $this->assertTrue( $exposure[ Read_Content::class ], 'read-content depends on core-object exposure.' ); + $this->assertTrue( $exposure[ Edit_Content::class ], 'edit-content depends on core-object exposure.' ); $this->assertFalse( $exposure[ Post_Utilities::class ], 'post utilities do not depend on core-object exposure.' ); $this->assertFalse( $exposure[ Read_Users::class ], 'read-users does not depend on core-object exposure.' ); } @@ -111,7 +114,7 @@ public function test_filter_can_add_a_gated_ability(): void { remove_filter( 'wpai_gated_abilities', $callback ); $this->assertContains( Test_Valid_Gated_Ability::class, $classes ); - $this->assertCount( 5, $classes ); + $this->assertCount( 6, $classes ); } /** @@ -134,7 +137,7 @@ public function test_filter_can_remove_a_gated_ability(): void { remove_filter( 'wpai_gated_abilities', $callback ); $this->assertNotContains( Read_Users::class, $classes ); - $this->assertCount( 3, $classes ); + $this->assertCount( 4, $classes ); } /** @@ -152,7 +155,7 @@ public function test_get_all_dedupes_classes(): void { $classes = array_map( 'get_class', Gated_Abilities::get_all() ); remove_filter( 'wpai_gated_abilities', $callback ); - $this->assertCount( 4, $classes ); + $this->assertCount( 5, $classes ); $this->assertCount( 1, array_keys( $classes, Post_Utilities::class, true ) ); }