@@ -776,4 +776,244 @@ public function test_filter_by_nonexistent_category(): void {
776776 $ this ->assertIsArray ( $ data );
777777 $ this ->assertEmpty ( $ data , 'Should return empty array for non-existent category ' );
778778 }
779+
780+ /**
781+ * Test that WordPress-internal schema keywords are stripped from ability schemas in REST response.
782+ *
783+ * @ticket 65035
784+ */
785+ public function test_internal_schema_keywords_stripped_from_response (): void {
786+ $ this ->register_test_ability (
787+ 'test/with-internal-keywords ' ,
788+ array (
789+ 'label ' => 'Test Internal Keywords ' ,
790+ 'description ' => 'Tests stripping of internal schema keywords ' ,
791+ 'category ' => 'general ' ,
792+ 'input_schema ' => array (
793+ 'type ' => 'object ' ,
794+ 'properties ' => array (
795+ 'content ' => array (
796+ 'type ' => 'string ' ,
797+ 'description ' => 'The content value. ' ,
798+ 'sanitize_callback ' => 'sanitize_text_field ' ,
799+ 'validate_callback ' => 'is_string ' ,
800+ 'arg_options ' => array ( 'sanitize_callback ' => 'wp_kses_post ' ),
801+ ),
802+ ),
803+ ),
804+ 'output_schema ' => array (
805+ 'type ' => 'string ' ,
806+ 'sanitize_callback ' => 'sanitize_text_field ' ,
807+ ),
808+ 'execute_callback ' => static function ( $ input ) {
809+ return $ input ['content ' ];
810+ },
811+ 'permission_callback ' => '__return_true ' ,
812+ 'meta ' => array ( 'show_in_rest ' => true ),
813+ )
814+ );
815+
816+ $ request = new WP_REST_Request ( 'GET ' , '/wp-abilities/v1/abilities/test/with-internal-keywords ' );
817+ $ response = $ this ->server ->dispatch ( $ request );
818+
819+ $ this ->assertSame ( 200 , $ response ->get_status () );
820+
821+ $ data = $ response ->get_data ();
822+ $ this ->assertArrayHasKey ( 'input_schema ' , $ data );
823+ $ this ->assertArrayHasKey ( 'properties ' , $ data ['input_schema ' ] );
824+ $ this ->assertArrayHasKey ( 'content ' , $ data ['input_schema ' ]['properties ' ] );
825+ $ this ->assertArrayHasKey ( 'output_schema ' , $ data );
826+
827+ // Verify internal keywords are stripped from input_schema properties.
828+ $ content_schema = $ data ['input_schema ' ]['properties ' ]['content ' ];
829+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ content_schema );
830+ $ this ->assertArrayNotHasKey ( 'validate_callback ' , $ content_schema );
831+ $ this ->assertArrayNotHasKey ( 'arg_options ' , $ content_schema );
832+
833+ // Verify valid JSON Schema keywords are preserved.
834+ $ this ->assertSame ( 'string ' , $ content_schema ['type ' ] );
835+ $ this ->assertSame ( 'The content value. ' , $ content_schema ['description ' ] );
836+
837+ // Verify internal keywords are stripped from output_schema.
838+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ data ['output_schema ' ] );
839+ $ this ->assertSame ( 'string ' , $ data ['output_schema ' ]['type ' ] );
840+ }
841+
842+ /**
843+ * Test that internal schema keywords are stripped from nested sub-schema locations.
844+ *
845+ * @ticket 64098
846+ */
847+ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas (): void {
848+ $ this ->register_test_ability (
849+ 'test/nested-internal-keywords ' ,
850+ array (
851+ 'label ' => 'Test Nested Keywords ' ,
852+ 'description ' => 'Tests stripping from all sub-schema locations ' ,
853+ 'category ' => 'general ' ,
854+ 'input_schema ' => array (
855+ 'type ' => 'object ' ,
856+ 'anyOf ' => array (
857+ array (
858+ 'type ' => 'object ' ,
859+ 'sanitize_callback ' => 'sanitize_text_field ' ,
860+ 'properties ' => array (
861+ 'value ' => array (
862+ 'type ' => 'string ' ,
863+ 'validate_callback ' => 'is_string ' ,
864+ ),
865+ ),
866+ ),
867+ array (
868+ 'type ' => 'number ' ,
869+ 'arg_options ' => array ( 'sanitize_callback ' => 'absint ' ),
870+ ),
871+ ),
872+ 'oneOf ' => array (
873+ array (
874+ 'type ' => 'string ' ,
875+ 'sanitize_callback ' => 'sanitize_text_field ' ,
876+ ),
877+ ),
878+ 'allOf ' => array (
879+ array (
880+ 'type ' => 'object ' ,
881+ 'validate_callback ' => 'rest_validate_request_arg ' ,
882+ ),
883+ ),
884+ 'not ' => array (
885+ 'type ' => 'null ' ,
886+ 'arg_options ' => array ( 'sanitize_callback ' => 'absint ' ),
887+ ),
888+ 'patternProperties ' => array (
889+ '^S_ ' => array (
890+ 'type ' => 'string ' ,
891+ 'sanitize_callback ' => 'sanitize_text_field ' ,
892+ ),
893+ ),
894+ 'definitions ' => array (
895+ 'address ' => array (
896+ 'type ' => 'object ' ,
897+ 'validate_callback ' => 'rest_validate_request_arg ' ,
898+ 'properties ' => array (
899+ 'street ' => array (
900+ 'type ' => 'string ' ,
901+ 'sanitize_callback ' => 'sanitize_text_field ' ,
902+ ),
903+ ),
904+ ),
905+ ),
906+ 'dependencies ' => array (
907+ 'bar ' => array (
908+ 'type ' => 'object ' ,
909+ 'validate_callback ' => 'rest_validate_request_arg ' ,
910+ 'properties ' => array (
911+ 'baz ' => array (
912+ 'type ' => 'string ' ,
913+ 'sanitize_callback ' => 'sanitize_text_field ' ,
914+ ),
915+ ),
916+ ),
917+ 'qux ' => array ( 'bar ' ),
918+ ),
919+ 'additionalProperties ' => array (
920+ 'type ' => 'string ' ,
921+ 'sanitize_callback ' => 'sanitize_text_field ' ,
922+ ),
923+ ),
924+ 'output_schema ' => array (
925+ 'type ' => 'array ' ,
926+ 'items ' => array (
927+ array (
928+ 'type ' => 'string ' ,
929+ 'validate_callback ' => 'is_string ' ,
930+ ),
931+ array (
932+ 'type ' => 'number ' ,
933+ 'arg_options ' => array ( 'sanitize_callback ' => 'absint ' ),
934+ ),
935+ ),
936+ 'additionalItems ' => array (
937+ 'type ' => 'boolean ' ,
938+ 'sanitize_callback ' => 'rest_sanitize_boolean ' ,
939+ ),
940+ ),
941+ 'execute_callback ' => static function ( $ input ) {
942+ return array ();
943+ },
944+ 'permission_callback ' => '__return_true ' ,
945+ 'meta ' => array ( 'show_in_rest ' => true ),
946+ )
947+ );
948+
949+ $ request = new WP_REST_Request ( 'GET ' , '/wp-abilities/v1/abilities/test/nested-internal-keywords ' );
950+ $ response = $ this ->server ->dispatch ( $ request );
951+
952+ $ this ->assertSame ( 200 , $ response ->get_status () );
953+
954+ $ data = $ response ->get_data ();
955+
956+ // Verify internal keywords are stripped from anyOf sub-schemas.
957+ $ this ->assertArrayHasKey ( 'anyOf ' , $ data ['input_schema ' ] );
958+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ data ['input_schema ' ]['anyOf ' ][0 ] );
959+ $ this ->assertSame ( 'object ' , $ data ['input_schema ' ]['anyOf ' ][0 ]['type ' ] );
960+ $ this ->assertArrayNotHasKey ( 'validate_callback ' , $ data ['input_schema ' ]['anyOf ' ][0 ]['properties ' ]['value ' ] );
961+ $ this ->assertSame ( 'string ' , $ data ['input_schema ' ]['anyOf ' ][0 ]['properties ' ]['value ' ]['type ' ] );
962+ $ this ->assertArrayNotHasKey ( 'arg_options ' , $ data ['input_schema ' ]['anyOf ' ][1 ] );
963+ $ this ->assertSame ( 'number ' , $ data ['input_schema ' ]['anyOf ' ][1 ]['type ' ] );
964+
965+ // Verify internal keywords are stripped from oneOf sub-schemas.
966+ $ this ->assertArrayHasKey ( 'oneOf ' , $ data ['input_schema ' ] );
967+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ data ['input_schema ' ]['oneOf ' ][0 ] );
968+ $ this ->assertSame ( 'string ' , $ data ['input_schema ' ]['oneOf ' ][0 ]['type ' ] );
969+
970+ // Verify internal keywords are stripped from allOf sub-schemas.
971+ $ this ->assertArrayHasKey ( 'allOf ' , $ data ['input_schema ' ] );
972+ $ this ->assertArrayNotHasKey ( 'validate_callback ' , $ data ['input_schema ' ]['allOf ' ][0 ] );
973+ $ this ->assertSame ( 'object ' , $ data ['input_schema ' ]['allOf ' ][0 ]['type ' ] );
974+
975+ // Verify internal keywords are stripped from not sub-schema.
976+ $ this ->assertArrayHasKey ( 'not ' , $ data ['input_schema ' ] );
977+ $ this ->assertArrayNotHasKey ( 'arg_options ' , $ data ['input_schema ' ]['not ' ] );
978+ $ this ->assertSame ( 'null ' , $ data ['input_schema ' ]['not ' ]['type ' ] );
979+
980+ // Verify internal keywords are stripped from patternProperties sub-schemas.
981+ $ this ->assertArrayHasKey ( 'patternProperties ' , $ data ['input_schema ' ] );
982+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ data ['input_schema ' ]['patternProperties ' ]['^S_ ' ] );
983+ $ this ->assertSame ( 'string ' , $ data ['input_schema ' ]['patternProperties ' ]['^S_ ' ]['type ' ] );
984+
985+ // Verify internal keywords are stripped from dependencies schema values.
986+ $ this ->assertArrayHasKey ( 'dependencies ' , $ data ['input_schema ' ] );
987+ $ this ->assertArrayNotHasKey ( 'validate_callback ' , $ data ['input_schema ' ]['dependencies ' ]['bar ' ] );
988+ $ this ->assertSame ( 'object ' , $ data ['input_schema ' ]['dependencies ' ]['bar ' ]['type ' ] );
989+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ data ['input_schema ' ]['dependencies ' ]['bar ' ]['properties ' ]['baz ' ] );
990+ $ this ->assertSame ( 'string ' , $ data ['input_schema ' ]['dependencies ' ]['bar ' ]['properties ' ]['baz ' ]['type ' ] );
991+ // Property dependencies (numeric arrays) should pass through unchanged.
992+ $ this ->assertSame ( array ( 'bar ' ), $ data ['input_schema ' ]['dependencies ' ]['qux ' ] );
993+
994+ // Verify internal keywords are stripped from definitions sub-schemas.
995+ $ this ->assertArrayHasKey ( 'definitions ' , $ data ['input_schema ' ] );
996+ $ this ->assertArrayNotHasKey ( 'validate_callback ' , $ data ['input_schema ' ]['definitions ' ]['address ' ] );
997+ $ this ->assertSame ( 'object ' , $ data ['input_schema ' ]['definitions ' ]['address ' ]['type ' ] );
998+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ data ['input_schema ' ]['definitions ' ]['address ' ]['properties ' ]['street ' ] );
999+ $ this ->assertSame ( 'string ' , $ data ['input_schema ' ]['definitions ' ]['address ' ]['properties ' ]['street ' ]['type ' ] );
1000+
1001+ // Verify internal keywords are stripped from additionalProperties sub-schema.
1002+ $ this ->assertArrayHasKey ( 'additionalProperties ' , $ data ['input_schema ' ] );
1003+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ data ['input_schema ' ]['additionalProperties ' ] );
1004+ $ this ->assertSame ( 'string ' , $ data ['input_schema ' ]['additionalProperties ' ]['type ' ] );
1005+
1006+ // Verify internal keywords are stripped from tuple-style items sub-schemas.
1007+ $ this ->assertArrayHasKey ( 'items ' , $ data ['output_schema ' ] );
1008+ $ this ->assertCount ( 2 , $ data ['output_schema ' ]['items ' ] );
1009+ $ this ->assertArrayNotHasKey ( 'validate_callback ' , $ data ['output_schema ' ]['items ' ][0 ] );
1010+ $ this ->assertSame ( 'string ' , $ data ['output_schema ' ]['items ' ][0 ]['type ' ] );
1011+ $ this ->assertArrayNotHasKey ( 'arg_options ' , $ data ['output_schema ' ]['items ' ][1 ] );
1012+ $ this ->assertSame ( 'number ' , $ data ['output_schema ' ]['items ' ][1 ]['type ' ] );
1013+
1014+ // Verify internal keywords are stripped from additionalItems sub-schema.
1015+ $ this ->assertArrayHasKey ( 'additionalItems ' , $ data ['output_schema ' ] );
1016+ $ this ->assertArrayNotHasKey ( 'sanitize_callback ' , $ data ['output_schema ' ]['additionalItems ' ] );
1017+ $ this ->assertSame ( 'boolean ' , $ data ['output_schema ' ]['additionalItems ' ]['type ' ] );
1018+ }
7791019}
0 commit comments