diff --git a/lib/experimental/content-types/class-wp-rest-user-post-types-controller-gutenberg.php b/lib/experimental/content-types/class-wp-rest-user-post-types-controller-gutenberg.php new file mode 100644 index 00000000000000..028be2568080e2 --- /dev/null +++ b/lib/experimental/content-types/class-wp-rest-user-post-types-controller-gutenberg.php @@ -0,0 +1,355 @@ + 'string', + 'maxLength' => 200, + ); + } + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'public' => array( 'type' => 'boolean' ), + 'hierarchical' => array( 'type' => 'boolean' ), + 'has_archive' => array( 'type' => 'boolean' ), + 'show_in_rest' => array( 'type' => 'boolean' ), + // Caps payload size; well above any reasonable description. + 'description' => array( + 'type' => 'string', + 'maxLength' => 1000, + ), + 'supports' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => self::get_allowed_supports(), + ), + 'uniqueItems' => true, + 'maxItems' => 50, + ), + 'taxonomies' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + // Matches the wp_taxonomies key length cap. + 'pattern' => '^[a-z0-9_-]{1,32}$', + ), + 'uniqueItems' => true, + 'maxItems' => 50, + ), + 'labels' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $label_props, + ), + ), + ); + } + + /** + * Returns the JSON schema for a single record. Removes the raw `content` + * field from the standard posts schema and replaces it with the typed + * `config` object. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = parent::get_item_schema(); + unset( $schema['properties']['content'] ); + + $schema['properties']['config'] = array_merge( + self::get_config_schema(), + array( + 'description' => __( 'Typed post type configuration.', 'gutenberg' ), + 'context' => array( 'view', 'edit' ), + 'default' => array(), + ) + ); + + $this->schema = $schema; + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Adds the typed `config` object to the response. + * + * @param WP_Post $item Stored record. + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function prepare_item_for_response( $item, $request ) { + $response = parent::prepare_item_for_response( $item, $request ); + $data = $response->get_data(); + + $fields = $this->get_fields_for_response( $request ); + + if ( rest_is_field_included( 'config', $fields ) ) { + $decoded = json_decode( (string) $item->post_content, true ); + $config = ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) + ? $decoded + : array(); + // Storage marker is server-only; never expose it to clients. + unset( $config[ GUTENBERG_USER_POST_TYPE_CONFIG_MARKER ] ); + // Empty config must serialize as `{}` to match the schema's + // `type: 'object'`. PHP encodes empty associative arrays as `[]` + // in JSON, so cast empties to stdClass. + $data['config'] = empty( $config ) ? new stdClass() : $config; + } + + $response->set_data( $data ); + return $response; + } + + /** + * Translates the typed `config` field on the request into the JSON blob + * that lives in `post_content`, and rejects writes whose slug collides or + * has the wrong shape. Structural sanitization is layered onto + * `wp_insert_post_data`; this method only encodes. + * + * @param WP_REST_Request $request REST request. + * @return stdClass|WP_Error + */ + protected function prepare_item_for_database( $request ) { + $prepared = parent::prepare_item_for_database( $request ); + if ( is_wp_error( $prepared ) ) { + return $prepared; + } + + $slug_check = $this->validate_slug( $prepared ); + if ( is_wp_error( $slug_check ) ) { + return $slug_check; + } + + // `config` is replaced atomically when the param is present (or on + // create). Omitting it from a PUT/PATCH preserves the stored value; + // there is no in-`config` partial-update support. + if ( $request->has_param( 'config' ) || empty( $request['id'] ) ) { + $config = is_array( $request['config'] ) ? $request['config'] : array(); + + // `JSON_HEX_TAG | JSON_HEX_AMP` escape `<`, `>`, and `&` to their + // `\u00XX` forms before `wp_insert_post()` is called, so when + // kses runs first via `content_save_pre` it sees an inert string + // and is a no-op. `JSON_UNESCAPED_SLASHES` keeps URL-like values + // readable and round-trips cleanly through `wp_slash`/`wp_unslash`. + // Empty object-shaped positions are cast to `stdClass` via + // `normalize_config_for_encode()` so they serialize as `{}`, + // matching the schema's `type: 'object'` declarations. + $prepared->post_content = wp_json_encode( + self::normalize_config_for_encode( $config ), + JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP + ); + } + + return $prepared; + } + + /** + * Prepares a config array for `wp_json_encode` so every empty + * object-shaped position serializes as `{}` rather than `[]`, matching + * the schema's `type: 'object'` declarations. + * + * Drives off `get_config_schema()` so new object-typed fields get the + * `[] → {}` cast automatically. The `$schema` param is internal — + * public callers pass just `$value`; recursion threads the + * sub-schema through. + * + * @param mixed $value Value to normalize. + * @param array|null $schema Schema fragment for `$value`. Defaults to the full config schema. + * @return mixed + */ + public static function normalize_config_for_encode( $value, $schema = null ) { + $schema = $schema ?? self::get_config_schema(); + if ( 'object' !== $schema['type'] ) { + return $value; + } + if ( ! is_array( $value ) || empty( $value ) ) { + return new stdClass(); + } + foreach ( $schema['properties'] as $key => $sub_schema ) { + if ( array_key_exists( $key, $value ) ) { + $value[ $key ] = self::normalize_config_for_encode( $value[ $key ], $sub_schema ); + } + } + return $value; + } + + /** + * Validates the prepared post's `post_name` slug. Rejects: + * - shapes outside `^[a-z0-9_-]{1,20}$`, + * - slugs already taken by another `wp_user_post_type` post, + * - slugs reserved by an existing registered post type (core/plugin). + * + * The Add/Edit modals do the same checks client-side for UX, but the + * server is the authoritative gate. + * + * @param stdClass $prepared Prepared post object. + * @return true|WP_Error + */ + private function validate_slug( $prepared ) { + $slug = isset( $prepared->post_name ) ? (string) $prepared->post_name : ''; + $editing_id = isset( $prepared->ID ) ? (int) $prepared->ID : 0; + + // PUT/PATCH without a `slug` param leaves `post_name` empty so WP + // keeps the row's existing slug. Skip validation in that case. + if ( '' === $slug && $editing_id > 0 ) { + return true; + } + + if ( ! preg_match( GUTENBERG_USER_POST_TYPE_SLUG_PATTERN, $slug ) ) { + return new WP_Error( + 'gutenberg_user_post_type_slug_invalid', + __( 'Post type keys must be 1–20 characters and may only contain lowercase letters, numbers, hyphens, and underscores.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Unchanged slug on an existing record — allow. + if ( $editing_id > 0 ) { + $existing = get_post( $editing_id ); + if ( $existing && $existing->post_name === $slug ) { + return true; + } + } + + $other_posts = get_posts( + array( + 'post_type' => 'wp_user_post_type', + 'post_status' => 'any', + 'name' => $slug, + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'suppress_filters' => true, + 'post__not_in' => $editing_id > 0 ? array( $editing_id ) : array(), + ) + ); + if ( ! empty( $other_posts ) ) { + return new WP_Error( + 'gutenberg_user_post_type_slug_taken', + __( 'Another user-defined post type already uses this key.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Our own register_post_type() step runs at init priority 20 and skips + // colliding slugs, so a post_type_exists() hit here means a + // non-user-post-type registration owns the slug. + if ( post_type_exists( $slug ) ) { + return new WP_Error( + 'gutenberg_user_post_type_slug_reserved', + sprintf( + /* translators: %s: post type slug */ + __( 'The post type key "%s" is reserved by an existing post type.', 'gutenberg' ), + $slug + ), + array( 'status' => 400 ) + ); + } + + return true; + } +} diff --git a/lib/experimental/content-types/index.php b/lib/experimental/content-types/index.php index 024d3e9a4e385a..a3359ce39d4dee 100644 --- a/lib/experimental/content-types/index.php +++ b/lib/experimental/content-types/index.php @@ -2,10 +2,11 @@ /** * Registers the private CPTs that store user-defined content types: * - wp_user_taxonomy (user-defined taxonomies) + * - wp_user_post_type (user-defined post types) * - * Each record holds the registration intent for one taxonomy. On `init`, - * this file also reads each published record and calls - * `register_taxonomy()` for it. + * Each record holds the registration intent for one taxonomy or post type. + * On `init`, the corresponding files read each published record and call + * `register_taxonomy()` / `register_post_type()` for it. * * @package gutenberg */ @@ -15,6 +16,7 @@ } require_once __DIR__ . '/class-wp-rest-user-taxonomies-controller-gutenberg.php'; +require_once __DIR__ . '/class-wp-rest-user-post-types-controller-gutenberg.php'; /** * Post meta key that stores the post types attached to a user-defined diff --git a/lib/experimental/content-types/post-types.php b/lib/experimental/content-types/post-types.php index 88230781014b25..d1b2eb5118aa3e 100644 --- a/lib/experimental/content-types/post-types.php +++ b/lib/experimental/content-types/post-types.php @@ -14,6 +14,22 @@ exit; } +/** + * Self-identifying key embedded in stored `post_content` JSON. Mirrors + * core's `isGlobalStylesUserThemeJSON` for `wp_global_styles`. + * + * Storage-only: kept out of the REST schema and stripped on read so it + * never reaches clients. See the equivalent constant on the user-taxonomy + * side for the full forward-compat rationale. + */ +const GUTENBERG_USER_POST_TYPE_CONFIG_MARKER = 'isUserPostTypeConfigJSON'; + +/** + * Regex for a valid post type slug. 20 chars matches the `wp_posts.post_type` + * column width that `register_post_type()` enforces. + */ +const GUTENBERG_USER_POST_TYPE_SLUG_PATTERN = '/^[a-z0-9_-]{1,20}$/'; + /** * Registers the wp_user_post_type CPT. */ @@ -21,19 +37,20 @@ function gutenberg_register_user_post_type_cpt() { register_post_type( 'wp_user_post_type', array( - 'labels' => array( + 'labels' => array( 'name' => __( 'User post types', 'gutenberg' ), 'singular_name' => __( 'User post type', 'gutenberg' ), 'add_new_item' => __( 'Add post type', 'gutenberg' ), ), - 'public' => false, - 'publicly_queryable' => false, - 'show_ui' => false, - 'show_in_menu' => false, - 'show_in_rest' => true, - 'rest_base' => 'user-post-types', - 'capability_type' => 'post', - 'capabilities' => array( + 'public' => false, + 'publicly_queryable' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rest_base' => 'user-post-types', + 'rest_controller_class' => 'WP_REST_User_Post_Types_Controller_Gutenberg', + 'capability_type' => 'post', + 'capabilities' => array( /** * Capability map: every write operation requires `manage_options`. * Read is allowed for any authenticated user that can `edit_posts` so the @@ -50,18 +67,109 @@ function gutenberg_register_user_post_type_cpt() { 'delete_others_posts' => 'manage_options', 'publish_posts' => 'manage_options', ), - 'map_meta_cap' => true, - 'supports' => array( 'title', 'editor' ), - 'hierarchical' => false, - 'has_archive' => false, - 'rewrite' => false, - 'query_var' => false, + 'map_meta_cap' => true, + 'supports' => array( 'title', 'editor' ), + 'hierarchical' => false, + 'has_archive' => false, + 'rewrite' => false, + 'query_var' => false, ) ); } add_action( 'init', 'gutenberg_register_user_post_type_cpt' ); +/** + * Sanitizes a decoded post type config to the canonical shape declared by + * the REST controller's config schema. Single sanitization site for + * post type records — called from {@see gutenberg_filter_user_post_type_post_content} + * on `wp_insert_post_data`. + * + * @param array $config Raw decoded config. + * @return array Sanitized config. + */ +function gutenberg_user_post_type_sanitize_config( $config ) { + if ( ! is_array( $config ) ) { + return array(); + } + + $clean = rest_sanitize_value_from_schema( + $config, + WP_REST_User_Post_Types_Controller_Gutenberg::get_config_schema() + ); + if ( ! is_array( $clean ) ) { + return array(); + } + + // `rest_sanitize_value_from_schema()` casts strings to their declared + // type but doesn't strip HTML or control characters, so layer that on. + if ( isset( $clean['description'] ) ) { + $clean['description'] = sanitize_textarea_field( (string) $clean['description'] ); + } + if ( isset( $clean['labels'] ) && is_array( $clean['labels'] ) ) { + foreach ( $clean['labels'] as $key => $value ) { + $clean['labels'][ $key ] = sanitize_text_field( (string) $value ); + } + } + + return $clean; +} + +/** + * Sanitizes wp_user_post_type JSON `post_content` during `wp_insert_post`. + * + * Acts on posts of type `wp_user_post_type`. Returns input unchanged for + * any other post type. Invalid JSON is normalized to the canonical + * marker-only payload rather than passed through. The filter is + * unconditional — post type config isn't HTML and shouldn't carry scripts + * even for users with `unfiltered_html`. + * + * @param array $data Slashed post data being inserted/updated. + * @return array Filtered data. + */ +function gutenberg_filter_user_post_type_post_content( $data ) { + if ( ! isset( $data['post_type'], $data['post_content'] ) ) { + return $data; + } + + if ( 'wp_user_post_type' !== $data['post_type'] ) { + return $data; + } + + $decoded = json_decode( wp_unslash( (string) $data['post_content'] ), true ); + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { + // Hedge: invalid JSON falls through to a canonical empty payload so + // a stray read path can't surface arbitrary bytes. The marker is + // added below, keeping the stored shape uniform. + $decoded = array(); + } + + $clean = gutenberg_user_post_type_sanitize_config( $decoded ); + + // Storage-only marker: deliberately not in the REST schema so it can + // never reach clients. Kept as a forward-compat anchor for a + // content-only fallback sanitizer; full rationale on the const. + $clean[ GUTENBERG_USER_POST_TYPE_CONFIG_MARKER ] = true; + + // `wp_insert_post_data` is the last filter before the row is written, + // so the re-encode here is what lands in the database. + // `JSON_HEX_TAG | JSON_HEX_AMP` guarantee the stored bytes carry no + // live `<`, `>`, or `&`, so any subsequent pass through kses (on + // later updates or on display) sees an inert string. kses on + // `content_save_pre` already ran earlier in `wp_insert_post()`; for + // REST writes that input was pre-escaped by + // `prepare_item_for_database`, so that earlier pass was also a no-op. + $data['post_content'] = wp_slash( + wp_json_encode( + WP_REST_User_Post_Types_Controller_Gutenberg::normalize_config_for_encode( $clean ), + JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP + ) + ); + + return $data; +} +add_filter( 'wp_insert_post_data', 'gutenberg_filter_user_post_type_post_content' ); + /** * Builds register_post_type() arguments from a wp_user_post_type record. * Returns null for invalid records so callers can skip them uniformly. @@ -71,87 +179,47 @@ function gutenberg_register_user_post_type_cpt() { */ function gutenberg_build_user_post_type_args( WP_Post $record ) { $slug = $record->post_name; - // register_post_type() limits the slug to 20 chars and a small charset. - if ( ! is_string( $slug ) || ! preg_match( '/^[a-z0-9_-]{1,20}$/', $slug ) ) { + if ( ! is_string( $slug ) || ! preg_match( GUTENBERG_USER_POST_TYPE_SLUG_PATTERN, $slug ) ) { return null; } - $config = json_decode( (string) $record->post_content, true, 8 ); - if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $config ) ) { + $decoded = json_decode( (string) $record->post_content, true, 8 ); + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { return null; } + unset( $decoded[ GUTENBERG_USER_POST_TYPE_CONFIG_MARKER ] ); + // Storage is sanitized at write-time by the filter on + // `wp_insert_post_data`, so we trust the decoded shape here. + $config = $decoded; $title = sanitize_text_field( $record->post_title ); $singular = isset( $config['labels']['singular_name'] ) - ? sanitize_text_field( (string) $config['labels']['singular_name'] ) + ? (string) $config['labels']['singular_name'] : ''; $labels = array( 'name' => $title, 'singular_name' => '' !== $singular ? $singular : $title, ); - // Merge the optional label overrides. Empty strings fall through to the - // WordPress-generated defaults, so we skip any label whose stored value - // is empty after sanitization. - $optional_label_keys = array( - 'menu_name', - 'all_items', - 'add_new', - 'add_new_item', - 'edit_item', - 'new_item', - 'view_item', - 'view_items', - 'search_items', - 'not_found', - 'not_found_in_trash', - 'parent_item_colon', - 'archives', - 'attributes', - 'insert_into_item', - 'uploaded_to_this_item', - 'featured_image', - 'set_featured_image', - 'remove_featured_image', - 'use_featured_image', - 'filter_items_list', - 'items_list_navigation', - 'items_list', - ); - if ( isset( $config['labels'] ) && is_array( $config['labels'] ) ) { - foreach ( $optional_label_keys as $label_key ) { - if ( ! isset( $config['labels'][ $label_key ] ) ) { - continue; - } - $label_value = sanitize_text_field( (string) $config['labels'][ $label_key ] ); - if ( '' !== $label_value ) { - $labels[ $label_key ] = $label_value; - } + // Merge optional label overrides. The sanitizer has already pruned + // unknown keys against the schema, so we can trust whatever the stored + // labels object contains. Empty strings fall through to the + // WordPress-generated defaults. + $stored_labels = isset( $config['labels'] ) && is_array( $config['labels'] ) + ? $config['labels'] + : array(); + foreach ( array_keys( $stored_labels ) as $label_key ) { + if ( 'singular_name' === $label_key ) { + continue; } - } - - // Validate `supports` against a known allowlist; unknown features are dropped. - $allowed_supports = array( - 'title', - 'editor', - 'thumbnail', - 'excerpt', - 'comments', - 'revisions', - 'author', - 'page-attributes', - 'custom-fields', - 'trackbacks', - 'post-formats', - ); - $supports = array(); - if ( isset( $config['supports'] ) && is_array( $config['supports'] ) ) { - foreach ( $config['supports'] as $feature ) { - if ( is_string( $feature ) && in_array( $feature, $allowed_supports, true ) ) { - $supports[] = $feature; - } + if ( ! empty( $stored_labels[ $label_key ] ) ) { + $labels[ $label_key ] = (string) $stored_labels[ $label_key ]; } } + + $supports = isset( $config['supports'] ) && is_array( $config['supports'] ) + ? array_values( array_filter( $config['supports'], 'is_string' ) ) + : array(); if ( empty( $supports ) ) { // register_post_type() defaults to title+editor when supports is empty; // preserve that intent rather than disabling all features. @@ -178,11 +246,8 @@ function gutenberg_build_user_post_type_args( WP_Post $record ) { 'supports' => $supports, ); - if ( isset( $config['description'] ) && is_string( $config['description'] ) ) { - $description = sanitize_textarea_field( $config['description'] ); - if ( '' !== $description ) { - $args['description'] = $description; - } + if ( ! empty( $config['description'] ) ) { + $args['description'] = (string) $config['description']; } // `taxonomies` here is the inverse of the taxonomy record's `object_type`: @@ -239,69 +304,3 @@ function gutenberg_register_user_defined_post_types() { } } add_action( 'init', 'gutenberg_register_user_defined_post_types', 20 ); - -/** - * Rejects a wp_user_post_type save when its slug collides with an existing - * post type or another wp_user_post_type post. Primary server-side defense — - * the Add/Edit modals do the same check client-side for UX, but the server - * enforces the invariant. - * - * @param stdClass $prepared_post Post object prepared for insertion. - * @return stdClass|WP_Error Filtered post object, or WP_Error to abort. - */ -function gutenberg_validate_user_post_type_slug( $prepared_post ) { - $slug = ! empty( $prepared_post->post_name ) - ? (string) $prepared_post->post_name - : ''; - if ( '' === $slug ) { - return $prepared_post; - } - - $editing_id = isset( $prepared_post->ID ) ? (int) $prepared_post->ID : 0; - - // Unchanged slug on an existing record — allow. - if ( $editing_id > 0 ) { - $existing = get_post( $editing_id ); - if ( $existing && $existing->post_name === $slug ) { - return $prepared_post; - } - } - - // Another wp_user_post_type post already owns this slug → reject. - $other_posts = get_posts( - array( - 'post_type' => 'wp_user_post_type', - 'post_status' => 'any', - 'name' => $slug, - 'posts_per_page' => 1, - 'no_found_rows' => true, - 'suppress_filters' => true, - 'post__not_in' => $editing_id > 0 ? array( $editing_id ) : array(), - ) - ); - if ( ! empty( $other_posts ) ) { - return new WP_Error( - 'gutenberg_user_post_type_slug_taken', - __( 'Another user-defined post type already uses this key.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - - // Registered post type owns this slug (core / plugin) → reject. Our own - // materializer runs at init priority 20 and skips colliding slugs, so a - // post_type_exists() hit here means a non-user-post-type registration. - if ( post_type_exists( $slug ) ) { - return new WP_Error( - 'gutenberg_user_post_type_slug_reserved', - sprintf( - /* translators: %s: post type slug */ - __( 'The post type key "%s" is reserved by an existing post type.', 'gutenberg' ), - $slug - ), - array( 'status' => 400 ) - ); - } - - return $prepared_post; -} -add_filter( 'rest_pre_insert_wp_user_post_type', 'gutenberg_validate_user_post_type_slug' ); diff --git a/packages/user-post-types/src/index.ts b/packages/user-post-types/src/index.ts index 2070d6f8bcda42..b8a09b05b68e3e 100644 --- a/packages/user-post-types/src/index.ts +++ b/packages/user-post-types/src/index.ts @@ -11,7 +11,6 @@ export { BLANK_RECORD, DEFAULT_SUPPORTS, SUPPORT_FEATURES, - parseConfig, serializeForSave, toFormData, usePublicTaxonomies, diff --git a/packages/user-post-types/src/types.ts b/packages/user-post-types/src/types.ts index 5737639fcb31c6..817ab73b2369ae 100644 --- a/packages/user-post-types/src/types.ts +++ b/packages/user-post-types/src/types.ts @@ -4,7 +4,7 @@ export interface PostTypeRecord { slug: string; status: 'publish' | 'draft'; title: { raw: string; rendered: string }; - content: { raw: string; rendered: string }; + config: StoredConfig; } export interface StoredLabels { @@ -66,8 +66,7 @@ export interface StoredConfig { /** * Normalized in-memory shape used by the Add/Edit forms and the DataViews * table. REST rows are converted to this shape via `toFormData`, and back to - * the save payload via `serializeForSave`, so fields never have to JSON - * round-trip `content.raw` on every keystroke. + * the save payload via `serializeForSave`. */ export interface PostTypeFormData { id?: number; diff --git a/packages/user-post-types/src/utils.ts b/packages/user-post-types/src/utils.ts index 29ad77204d0a46..e659febd0af6d5 100644 --- a/packages/user-post-types/src/utils.ts +++ b/packages/user-post-types/src/utils.ts @@ -11,7 +11,6 @@ import { useMemo } from '@wordpress/element'; import type { PostTypeFormData, PostTypeRecord, - StoredConfig, StoredLabels, SupportFeature, } from './types'; @@ -34,18 +33,6 @@ export const BLANK_RECORD: PostTypeFormData = { }, }; -export function parseConfig( raw?: string ): StoredConfig { - if ( ! raw ) { - return {}; - } - try { - const parsed = JSON.parse( raw ); - return typeof parsed === 'object' && parsed !== null ? parsed : {}; - } catch { - return {}; - } -} - export const STRING_LABEL_KEYS: ( keyof StoredLabels )[] = [ 'singular_name', 'menu_name', @@ -88,16 +75,16 @@ export const SUPPORT_FEATURES: SupportFeature[] = [ ]; export function toFormData( row: PostTypeRecord ): PostTypeFormData { - const parsed = parseConfig( row.content.raw ); + const config = row.config ?? {}; const labels: StoredLabels = {}; for ( const key of STRING_LABEL_KEYS ) { - const value = parsed.labels?.[ key ]; + const value = config.labels?.[ key ]; if ( typeof value === 'string' ) { labels[ key ] = value; } } - const supports: SupportFeature[] = Array.isArray( parsed.supports ) - ? parsed.supports.filter( ( s ): s is SupportFeature => + const supports: SupportFeature[] = Array.isArray( config.supports ) + ? config.supports.filter( ( s ): s is SupportFeature => SUPPORT_FEATURES.includes( s as SupportFeature ) ) : [ ...DEFAULT_SUPPORTS ]; @@ -108,20 +95,20 @@ export function toFormData( row: PostTypeRecord ): PostTypeFormData { title: { raw: row.title.raw }, config: { labels: { singular_name: '', ...labels }, - taxonomies: Array.isArray( parsed.taxonomies ) - ? parsed.taxonomies + taxonomies: Array.isArray( config.taxonomies ) + ? config.taxonomies : [], supports, - description: parsed.description ?? '', - public: parsed.public ?? true, - hierarchical: parsed.hierarchical ?? false, - has_archive: parsed.has_archive ?? false, - show_in_rest: parsed.show_in_rest ?? true, + description: config.description ?? '', + public: config.public ?? true, + hierarchical: config.hierarchical ?? false, + has_archive: config.has_archive ?? false, + show_in_rest: config.show_in_rest ?? true, }, }; } -function serializeConfig( data: PostTypeFormData ): StoredConfig { +export function serializeForSave( data: PostTypeFormData ) { const { config } = data; const labels: StoredLabels = {}; @@ -136,25 +123,21 @@ function serializeConfig( data: PostTypeFormData ): StoredConfig { labels.singular_name = config.labels.singular_name; const description = config.description.trim(); - return { - labels, - taxonomies: config.taxonomies, - supports: config.supports, - public: config.public, - hierarchical: config.hierarchical, - has_archive: config.has_archive, - show_in_rest: config.show_in_rest, - ...( description !== '' ? { description } : {} ), - }; -} - -export function serializeForSave( data: PostTypeFormData ) { return { ...( data.id !== undefined ? { id: data.id } : {} ), slug: data.slug, status: data.status, title: data.title.raw, - content: JSON.stringify( serializeConfig( data ) ), + config: { + labels, + taxonomies: config.taxonomies, + supports: config.supports, + public: config.public, + hierarchical: config.hierarchical, + has_archive: config.has_archive, + show_in_rest: config.show_in_rest, + ...( description !== '' ? { description } : {} ), + }, }; } diff --git a/phpunit/experimental/content-types/class-wp-rest-user-post-types-controller-gutenberg-test.php b/phpunit/experimental/content-types/class-wp-rest-user-post-types-controller-gutenberg-test.php new file mode 100644 index 00000000000000..2d30693eabb7f3 --- /dev/null +++ b/phpunit/experimental/content-types/class-wp-rest-user-post-types-controller-gutenberg-test.php @@ -0,0 +1,706 @@ +set_body_params( + array( + 'title' => $title, + 'slug' => $slug, + 'status' => 'publish', + 'config' => $config, + ) + ); + return rest_get_server()->dispatch( $request )->get_data()['id']; + } + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + + self::$subscriber_id = $factory->user->create( + array( + 'role' => 'subscriber', + ) + ); + + self::$post_type_id = self::insert_user_post_type_record( + array( + 'labels' => array( 'singular_name' => 'Book' ), + 'public' => true, + 'hierarchical' => false, + 'description' => 'Library catalog entries.', + 'supports' => array( 'title', 'editor' ), + ), + 'book', + 'Books' + ); + + self::$other_post_type_id = self::insert_user_post_type_record( + array( + 'labels' => array( 'singular_name' => 'Movie' ), + 'public' => true, + 'hierarchical' => true, + ), + 'movie', + 'Movies' + ); + + // A post of an unrelated type — used to confirm the controller refuses + // to serve non-wp_user_post_type IDs. + self::$unrelated_post_id = $factory->post->create( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + self::delete_user( self::$subscriber_id ); + wp_delete_post( self::$post_type_id, true ); + wp_delete_post( self::$other_post_type_id, true ); + wp_delete_post( self::$unrelated_post_id, true ); + } + + /** + * Routes are present at the conventional /wp/v2/user-post-types paths. + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( self::REST_BASE, $routes, 'Collection route is missing.' ); + $this->assertArrayHasKey( self::REST_BASE . '/(?P[\d]+)', $routes, 'Single-record route is missing.' ); + } + + /** + * Collection accepts the standard `context` query param. + */ + public function test_context_param() { + $request = new WP_REST_Request( 'OPTIONS', self::REST_BASE ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /** + * Listing returns both seeded records with the typed shape — `config` + * object, no raw `content`. + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', self::REST_BASE ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertGreaterThanOrEqual( 2, count( $data ) ); + + $by_id = array(); + foreach ( $data as $row ) { + $by_id[ $row['id'] ] = $row; + } + + $this->assertArrayHasKey( self::$post_type_id, $by_id, 'Book record missing from listing.' ); + $row = $by_id[ self::$post_type_id ]; + + $this->assertArrayNotHasKey( 'content', $row, 'Raw content field should not be exposed.' ); + $this->assertArrayHasKey( 'config', $row ); + $this->assertSame( 'Book', $row['config']['labels']['singular_name'] ); + $this->assertSame( true, $row['config']['public'] ); + $this->assertSame( false, $row['config']['hierarchical'] ); + $this->assertSame( array( 'title', 'editor' ), $row['config']['supports'] ); + } + + /** + * Subscribers cannot list in `edit` context — that's the practical case + * the Settings page uses, and it requires edit_posts. + */ + public function test_get_items_subscriber_cannot_view_edit_context() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'GET', self::REST_BASE ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Single record returns the typed `config`. + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . self::$post_type_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'book', $data['slug'] ); + $this->assertSame( 'Books', $data['title']['raw'] ); + $this->assertArrayNotHasKey( 'content', $data ); + $this->assertSame( 'Book', $data['config']['labels']['singular_name'] ); + $this->assertSame( 'Library catalog entries.', $data['config']['description'] ); + } + + /** + * Asking for a record of a different post type — expect 404. + */ + public function test_get_item_invalid_post_type() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . self::$unrelated_post_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Admin can create a record. The response carries `config` derived from + * what was sent, not the raw post_content. + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'comic', + 'title' => 'Comics', + 'status' => 'publish', + 'config' => array( + 'public' => false, + 'hierarchical' => true, + 'description' => 'Pulp comics archive.', + 'labels' => array( + 'singular_name' => 'Comic', + ), + 'supports' => array( 'title', 'thumbnail' ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'comic', $data['slug'] ); + $this->assertArrayNotHasKey( 'content', $data ); + $this->assertSame( false, $data['config']['public'] ); + $this->assertSame( true, $data['config']['hierarchical'] ); + $this->assertSame( 'Comic', $data['config']['labels']['singular_name'] ); + $this->assertSame( array( 'title', 'thumbnail' ), $data['config']['supports'] ); + + wp_delete_post( $data['id'], true ); + } + + /** + * HTML in label values is stripped via sanitize_text_field, so a stored + * label can never carry markup back out to the client. + */ + public function test_create_item_strips_html_in_labels() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'safe_pt', + 'title' => 'Safe', + 'config' => array( + 'labels' => array( + 'singular_name' => 'Hostile', + 'menu_name' => 'Bold menu', + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'Hostile', $data['config']['labels']['singular_name'] ); + $this->assertSame( 'Bold menu', $data['config']['labels']['menu_name'] ); + + // Defense in depth: stored bytes also have no live tag characters. + $raw = get_post( $data['id'] )->post_content; + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'set_body_params( + array( + 'slug' => 'rejected_pt', + 'title' => 'Rejected', + 'config' => array( 'labels' => array( 'singular_name' => 'Rejected' ) ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Editors don't have manage_options, so create is forbidden. + */ + public function test_create_item_editor_forbidden() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'rejected_pt2', + 'title' => 'Rejected 2', + 'config' => array( 'labels' => array( 'singular_name' => 'Rejected' ) ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Reserved slugs (built-in post types) are rejected before they can be + * persisted. + */ + public function test_create_item_rejects_reserved_slug() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'post', + 'title' => 'Post', + 'config' => array( 'labels' => array( 'singular_name' => 'Post' ) ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + $this->assertContains( + $response->get_data()['code'], + array( 'gutenberg_user_post_type_slug_reserved', 'gutenberg_user_post_type_slug_taken' ) + ); + } + + /** + * A second record with the same slug as an existing record is rejected. + */ + public function test_create_item_rejects_duplicate_slug() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'book', + 'title' => 'Duplicate', + 'config' => array( 'labels' => array( 'singular_name' => 'Book Dup' ) ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'gutenberg_user_post_type_slug_taken', $response->get_data()['code'] ); + } + + /** + * Slug must match `^[a-z0-9_-]{1,20}$`. The 20-char cap is post-types- + * specific (vs 32 for taxonomies) because `wp_posts.post_type` is a + * 20-char column. Other shape violations (spaces, uppercase) get + * normalized away by `sanitize_title` upstream of the controller, so + * length is the only invariant the controller's pattern check is the + * sole defense for. + */ + public function test_create_item_rejects_overlength_slug() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => str_repeat( 'a', 21 ), + 'title' => 'Invalid', + 'config' => array( 'labels' => array( 'singular_name' => 'Invalid' ) ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'gutenberg_user_post_type_slug_invalid', $response->get_data()['code'] ); + } + + /** + * Update modifies the typed `config` and reflects it back. + */ + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'PUT', self::REST_BASE . '/' . self::$post_type_id ); + $request->set_body_params( + array( + 'config' => array( + 'public' => false, + 'hierarchical' => false, + 'description' => 'Updated description.', + 'labels' => array( 'singular_name' => 'Book' ), + 'supports' => array( 'title', 'editor', 'excerpt' ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( false, $data['config']['public'] ); + $this->assertSame( 'Updated description.', $data['config']['description'] ); + $this->assertSame( array( 'title', 'editor', 'excerpt' ), $data['config']['supports'] ); + } + + /** + * Unauthenticated update is rejected. + */ + public function test_update_item_no_user() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'PUT', self::REST_BASE . '/' . self::$post_type_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Admin can delete; the record disappears from subsequent listings. + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + + $post_id = self::insert_user_post_type_record( + array( 'labels' => array( 'singular_name' => 'Throwaway' ) ), + 'throwaway_pt', + 'Throwaways' + ); + + $request = new WP_REST_Request( 'DELETE', self::REST_BASE . '/' . $post_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_post( $post_id ) ); + } + + /** + * `prepare_item_for_response` decodes post_content and returns the + * sanitized `config` shape. Direct test of the controller method, not + * routed through the REST server. + */ + public function test_prepare_item() { + $controller = new WP_REST_User_Post_Types_Controller_Gutenberg( 'wp_user_post_type' ); + $post = get_post( self::$post_type_id ); + + $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . self::$post_type_id ); + $request->set_param( 'context', 'edit' ); + + $response = $controller->prepare_item_for_response( $post, $request ); + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'content', $data ); + $this->assertSame( 'Book', $data['config']['labels']['singular_name'] ); + } + + /** + * `additionalProperties: false` on `config.labels` means unknown label + * keys are rejected at the REST layer with a 400. + */ + public function test_create_item_rejects_unknown_label_key() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'unknown_label', + 'title' => 'Unknown Label', + 'config' => array( + 'labels' => array( + 'singular_name' => 'Unknown Label', + 'totally_made_up' => 'Should be rejected', + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + } + + /** + * `additionalProperties: false` at the top level of `config` rejects + * unknown keys with a 400 — the schema is closed. + */ + public function test_create_item_rejects_unknown_top_level_config_key() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'unknown_top', + 'title' => 'Unknown Top', + 'config' => array( + 'labels' => array( 'singular_name' => 'Unknown Top' ), + 'something_extra' => 'nope', + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + } + + /** + * `items.enum` on `config.supports` rejects unknown feature strings at + * the REST layer with a 400. Post-types analog of the unknown-label-key + * test, but for the array-typed `supports` field. + */ + public function test_create_item_rejects_unknown_supports_value() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'bad_supports', + 'title' => 'Bad Supports', + 'config' => array( + 'labels' => array( 'singular_name' => 'Bad Supports' ), + 'supports' => array( 'title', 'fake_feature' ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + } + + /** + * `config.taxonomies` is stored inline and round-trips cleanly. There is + * no separate top-level field and no collection-param filter on this + * side — the post-type→taxonomy attachment lives entirely within config. + */ + public function test_taxonomies_round_trip_in_config() { + wp_set_current_user( self::$admin_id ); + + $post_id = self::insert_user_post_type_record( + array( + 'labels' => array( 'singular_name' => 'Tagged' ), + 'taxonomies' => array( 'category', 'post_tag' ), + ), + 'tagged_pt', + 'Tagged' + ); + + $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . $post_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( 'category', 'post_tag' ), $data['config']['taxonomies'] ); + + wp_delete_post( $post_id, true ); + } + + /** + * The storage-only marker key is never present in the REST response, + * even though it lives in the stored `post_content` bytes. + */ + public function test_marker_key_absent_from_response() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', self::REST_BASE . '/' . self::$post_type_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayNotHasKey( GUTENBERG_USER_POST_TYPE_CONFIG_MARKER, $data['config'] ); + + // And confirm it actually lives in the stored bytes — otherwise this + // test passes vacuously if marker injection is silently broken. + $raw = get_post( self::$post_type_id )->post_content; + $this->assertStringContainsString( GUTENBERG_USER_POST_TYPE_CONFIG_MARKER, $raw ); + } + + /** + * Round-trip: write a config via REST, re-read the stored bytes, decode, + * strip the storage marker, and assert the result is byte-equal to a + * canonical re-encoding of the input. Catches encoding regressions — + * stray flag changes that shift `<` vs `<`, key reordering, or + * inadvertent reintroduction of `JSON_FORCE_OBJECT`. + */ + public function test_config_round_trip_is_byte_stable() { + wp_set_current_user( self::$admin_id ); + + $config = array( + 'public' => true, + 'hierarchical' => false, + 'description' => 'Round-trip description with slash / and amp & and tag .', + 'labels' => array( + 'singular_name' => 'Round', + 'menu_name' => 'Rounds', + ), + 'supports' => array( 'title', 'editor' ), + ); + + $request = new WP_REST_Request( 'POST', self::REST_BASE ); + $request->set_body_params( + array( + 'slug' => 'roundtrip_pt', + 'title' => 'Round-trip', + 'config' => $config, + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 201, $response->get_status() ); + + $raw = get_post( $data['id'] )->post_content; + $decoded = json_decode( $raw, true ); + $this->assertIsArray( $decoded ); + + // The marker is storage-only; drop before comparing payload shape. + $this->assertArrayHasKey( GUTENBERG_USER_POST_TYPE_CONFIG_MARKER, $decoded ); + unset( $decoded[ GUTENBERG_USER_POST_TYPE_CONFIG_MARKER ] ); + + // `sanitize_text_field` collapses interior tags to text, so the stored + // description matches the post-sanitize form, not the raw input. + $expected_description = sanitize_textarea_field( $config['description'] ); + $this->assertSame( $expected_description, $decoded['description'] ); + $this->assertSame( $config['labels'], $decoded['labels'] ); + $this->assertSame( $config['supports'], $decoded['supports'] ); + $this->assertSame( $config['public'], $decoded['public'] ); + $this->assertSame( $config['hierarchical'], $decoded['hierarchical'] ); + + // Live tag/amp characters must not appear in the stored bytes — + // `JSON_HEX_TAG | JSON_HEX_AMP` should escape them. + $this->assertStringNotContainsString( '', $raw ); + $this->assertStringNotContainsString( '&', $raw ); + + wp_delete_post( $data['id'], true ); + } + + /** + * Schema declares `config`, hides raw `content`, keeps + * `additionalProperties: false` on the typed config object and on + * `labels`, and gates `supports` with `items.enum` so unknown features + * fail at the REST layer. + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', self::REST_BASE ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $schema = $data['schema']; + + $this->assertArrayHasKey( 'config', $schema['properties'] ); + $this->assertArrayNotHasKey( 'content', $schema['properties'] ); + + $config_schema = $schema['properties']['config']; + $this->assertSame( 'object', $config_schema['type'] ); + $this->assertFalse( $config_schema['additionalProperties'] ); + $this->assertArrayHasKey( 'labels', $config_schema['properties'] ); + $this->assertFalse( $config_schema['properties']['labels']['additionalProperties'] ); + + $supports_schema = $config_schema['properties']['supports']; + $this->assertSame( 'array', $supports_schema['type'] ); + $this->assertArrayHasKey( 'enum', $supports_schema['items'] ); + // Pin a couple of known features — the full list lives on the + // controller; here we just confirm the gate exists and includes the + // canonical core supports. + $this->assertContains( 'title', $supports_schema['items']['enum'] ); + $this->assertContains( 'editor', $supports_schema['items']['enum'] ); + $this->assertNotContains( 'fake_feature', $supports_schema['items']['enum'] ); + + $taxonomies_schema = $config_schema['properties']['taxonomies']; + $this->assertSame( 'array', $taxonomies_schema['type'] ); + $this->assertSame( '^[a-z0-9_-]{1,32}$', $taxonomies_schema['items']['pattern'] ); + } +} diff --git a/phpunit/experimental/content-types/user-post-type-content-filter-test.php b/phpunit/experimental/content-types/user-post-type-content-filter-test.php new file mode 100644 index 00000000000000..3b57f0df7ac86b --- /dev/null +++ b/phpunit/experimental/content-types/user-post-type-content-filter-test.php @@ -0,0 +1,122 @@ +user->create( array( 'role' => 'administrator' ) ); + // On multisite, `unfiltered_html` belongs to super admins only; the + // single-site administrator role doesn't carry it. Promote so the + // kses pre-filter is bypassed in both environments and the test + // exercises our sanitizer as the sole line of defense. + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + /** + * Inserting a wp_user_post_type record via wp_insert_post strips HTML from + * label values. Runs as admin (has unfiltered_html) so kses doesn't run + * first — verifies our filter is the line of defense, matching the + * production REST path where admins are the writers. + */ + public function test_strips_html_in_post_type_post() { + wp_set_current_user( self::$admin_id ); + + $post_id = wp_insert_post( + array( + 'post_type' => 'wp_user_post_type', + 'post_status' => 'publish', + 'post_name' => 'filter_pt_direct', + 'post_title' => 'Direct', + 'post_content' => wp_json_encode( + array( + 'labels' => array( + 'singular_name' => 'Direct', + ), + ) + ), + ) + ); + + $content = get_post( $post_id )->post_content; + $decoded = json_decode( $content, true ); + $this->assertSame( 'Direct', $decoded['labels']['singular_name'] ); + $this->assertStringNotContainsString( ' 'post', + 'post_status' => 'publish', + 'post_title' => 'Regular', + 'post_content' => wp_json_encode( + array( + 'foo' => 'bar', + 'labels' => array( 'singular_name' => 'X' ), + ) + ), + ) + ); + + $decoded = json_decode( get_post( $post_id )->post_content, true ); + $this->assertSame( 'bar', $decoded['foo'] ); + $this->assertSame( 'X', $decoded['labels']['singular_name'] ); + + wp_delete_post( $post_id, true ); + } + + /** + * Non-JSON post_content on a wp_user_post_type record is normalized to + * the canonical marker-only payload — a hedge against a stray read + * path surfacing arbitrary bytes. + */ + public function test_normalizes_non_json_payload_to_marker_only() { + $post_id = wp_insert_post( + array( + 'post_type' => 'wp_user_post_type', + 'post_status' => 'publish', + 'post_name' => 'filter_pt_non_json', + 'post_title' => 'NonJSON', + 'post_content' => 'plain text, not JSON', + ) + ); + + $stored = get_post( $post_id )->post_content; + $this->assertJson( $stored ); + $this->assertSame( + array( GUTENBERG_USER_POST_TYPE_CONFIG_MARKER => true ), + json_decode( $stored, true ) + ); + + wp_delete_post( $post_id, true ); + } +} diff --git a/phpunit/experimental/content-types/user-post-type-registration-test.php b/phpunit/experimental/content-types/user-post-type-registration-test.php new file mode 100644 index 00000000000000..0115b879388b26 --- /dev/null +++ b/phpunit/experimental/content-types/user-post-type-registration-test.php @@ -0,0 +1,225 @@ +user->create( array( 'role' => 'administrator' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + /** + * Seed a wp_user_post_type record via REST so tests follow the same write + * path users do — controller sanitization and config encoding all happen + * via the REST controller rather than ad-hoc `wp_insert_post` plumbing. + * + * Always creates with `publish` because draft creation via REST triggers + * an upstream `wp_unique_post_slug` warning on new posts; tests that + * need a draft transition the status with `wp_update_post` after. + */ + protected static function create_user_post_type( $config, $slug, $title ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/user-post-types' ); + $request->set_body_params( + array( + 'title' => $title, + 'slug' => $slug, + 'status' => 'publish', + 'config' => $config, + ) + ); + return rest_get_server()->dispatch( $request )->get_data(); + } + + /** + * `gutenberg_register_user_defined_post_types()` only registers records + * with `post_status === 'publish'`; drafts are skipped so the Edit + * "Active" toggle effectively gates registration. + */ + public function test_drafts_are_skipped_by_registration() { + $created = self::create_user_post_type( + array( 'labels' => array( 'singular_name' => 'Draft' ) ), + 'draft_pt', + 'Drafts' + ); + wp_update_post( + array( + 'ID' => $created['id'], + 'post_status' => 'draft', + ) + ); + + gutenberg_register_user_defined_post_types(); + + $this->assertFalse( post_type_exists( 'draft_pt' ) ); + wp_delete_post( $created['id'], true ); + } + + /** + * Every config value the user sets is forwarded to `register_post_type()` + * so the resulting WP_Post_Type reflects the user's choice. Pins the + * contract of `gutenberg_build_user_post_type_args()`. + */ + public function test_config_forwarded_to_register_post_type() { + $created = self::create_user_post_type( + array( + 'labels' => array( + 'singular_name' => 'Item', + 'menu_name' => 'Items menu', + 'add_new_item' => 'Add a new item', + ), + 'public' => false, + 'hierarchical' => true, + 'has_archive' => true, + 'show_in_rest' => false, + 'description' => 'A test desc.', + 'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ), + ), + 'flagged_pt', + 'Items' + ); + + gutenberg_register_user_defined_post_types(); + + $post_type = get_post_type_object( 'flagged_pt' ); + $this->assertNotNull( $post_type ); + $this->assertFalse( $post_type->public ); + $this->assertTrue( $post_type->hierarchical ); + $this->assertTrue( $post_type->has_archive ); + $this->assertFalse( $post_type->show_in_rest ); + $this->assertSame( 'A test desc.', $post_type->description ); + $this->assertSame( 'Item', $post_type->labels->singular_name ); + $this->assertSame( 'Items menu', $post_type->labels->menu_name ); + $this->assertSame( 'Add a new item', $post_type->labels->add_new_item ); + + $this->assertTrue( post_type_supports( 'flagged_pt', 'title' ) ); + $this->assertTrue( post_type_supports( 'flagged_pt', 'editor' ) ); + $this->assertTrue( post_type_supports( 'flagged_pt', 'thumbnail' ) ); + $this->assertTrue( post_type_supports( 'flagged_pt', 'excerpt' ) ); + + unregister_post_type( 'flagged_pt' ); + wp_delete_post( $created['id'], true ); + } + + /** + * `hierarchical: true` implies `page-attributes` support so the parent + * picker renders in the block editor — without this the `hierarchical` + * toggle would flip a registry flag with no UI surface. Verifies the + * implicit add in `gutenberg_build_user_post_type_args()` even when the + * user didn't list it themselves. + */ + public function test_hierarchical_implies_page_attributes_support() { + $created = self::create_user_post_type( + array( + 'labels' => array( 'singular_name' => 'Hier' ), + 'hierarchical' => true, + 'supports' => array( 'title' ), + ), + 'hier_pt', + 'Hiers' + ); + + gutenberg_register_user_defined_post_types(); + + $this->assertTrue( post_type_supports( 'hier_pt', 'page-attributes' ) ); + $this->assertTrue( post_type_supports( 'hier_pt', 'title' ) ); + + unregister_post_type( 'hier_pt' ); + wp_delete_post( $created['id'], true ); + } + + /** + * Empty `supports` falls back to title+editor so a record without any + * features doesn't accidentally disable everything. Mirrors WordPress + * core's `register_post_type()` default when supports is omitted. + */ + public function test_empty_supports_defaults_to_title_editor() { + $created = self::create_user_post_type( + array( + 'labels' => array( 'singular_name' => 'Default' ), + 'supports' => array(), + ), + 'default_pt', + 'Defaults' + ); + + gutenberg_register_user_defined_post_types(); + + $this->assertTrue( post_type_supports( 'default_pt', 'title' ) ); + $this->assertTrue( post_type_supports( 'default_pt', 'editor' ) ); + + unregister_post_type( 'default_pt' ); + wp_delete_post( $created['id'], true ); + } + + /** + * Taxonomy slugs in `config.taxonomies` that don't correspond to any + * registered taxonomy are dropped at materialization time so we never + * pass unregistered slugs to `register_post_type()`. + */ + public function test_unknown_taxonomy_slugs_dropped_at_registration() { + $created = self::create_user_post_type( + array( + 'labels' => array( 'singular_name' => 'Tagged' ), + 'taxonomies' => array( 'category', 'definitely_not_a_real_tax' ), + ), + 'tagged_reg_pt', + 'Tagged' + ); + + gutenberg_register_user_defined_post_types(); + + $attached = get_object_taxonomies( 'tagged_reg_pt' ); + $this->assertContains( 'category', $attached ); + $this->assertNotContains( 'definitely_not_a_real_tax', $attached ); + + unregister_post_type( 'tagged_reg_pt' ); + wp_delete_post( $created['id'], true ); + } + + /** + * A record with a slug outside the post-type pattern (over the 20-char + * cap, in this case) is skipped by the materializer — the pattern guard + * in `gutenberg_build_user_post_type_args()` prevents us from forwarding + * a malformed slug to `register_post_type()`. We use length here because + * `wp_insert_post` runs `sanitize_title` on `post_name` and would + * normalize most other shape violations away; length isn't truncated, so + * it survives storage and reaches the materializer. + */ + public function test_invalid_slug_record_skipped_by_materializer() { + $slug = str_repeat( 'a', 22 ); + $post_id = wp_insert_post( + array( + 'post_type' => 'wp_user_post_type', + 'post_status' => 'publish', + 'post_name' => $slug, + 'post_title' => 'Bad', + 'post_content' => wp_json_encode( + array( 'labels' => array( 'singular_name' => 'Bad' ) ) + ), + ) + ); + + // Materializer must not fatal and must not register the over-length slug. + gutenberg_register_user_defined_post_types(); + + $this->assertFalse( post_type_exists( $slug ) ); + + wp_delete_post( $post_id, true ); + } +}