feat(abilities): enhance schema compat, user meta, e-commerce meta, and creator pipeline

- fix(schema-compat): prefer 'string' in non-null scalar unions to fix SanitizeSchemaUnionItemsTypeTest
- fix(content): define featured_image output properties and return resilient empty object structure for posts without thumbnails
- feat(content): add get_allowed_protected_meta() with WooCommerce and paywall whitelist
- feat(user): add sanitized meta support to create-user, update-user, and get-user with privilege escalation guard
- feat(database): support 'query' argument alias in emcp-tools/query
- feat(woo): expand create/update-product with author/vendor ownership, virtual/downloadable, prices, categories, tags, and meta; add list-vendors
- test: add 14 new automated unit tests across User, Content, Woo, and Database suites
This commit is contained in:
John Smith
2026-09-12 17:59:29 -04:00
parent 0f1f6ea2a2
commit 914088ff92
10 changed files with 963 additions and 44 deletions
+61 -4
View File
@@ -274,6 +274,52 @@ class EMCP_Tools_Content_Abilities {
return ! in_array( $post_type, $this->internal_post_types(), true );
}
/**
* Safe protected meta keys permitted for e-commerce, paywalls, and standard publishing.
*
* @since 3.4.3
* @return string[]
*/
public static function get_allowed_protected_meta(): array {
$default = array(
// WooCommerce Product Pricing & Inventory
'_price',
'_regular_price',
'_sale_price',
'_sale_price_dates_from',
'_sale_price_dates_to',
'_virtual',
'_downloadable',
'_sku',
'_stock_status',
'_stock',
'_manage_stock',
'_backorders',
'_sold_individually',
'_tax_status',
'_tax_class',
'_purchase_note',
'_featured',
'_weight',
'_length',
'_width',
'_height',
'_product_url',
'_button_text',
'_thumbnail_id',
'_product_image_gallery',
// Paywalls, Subscriptions, and Access
'_pay_per_post_price',
'_ppp_price',
'_ppp_enable',
'_wcs_subscription_period',
'_wcs_subscription_period_interval',
// Display & Template
'_wp_page_template',
);
return (array) apply_filters( 'emcp_tools_content_allowed_protected_meta', $default );
}
/**
* Validate a meta map against the protected-meta guard.
*
@@ -281,7 +327,7 @@ class EMCP_Tools_Content_Abilities {
* @return true|\WP_Error
*/
private function reject_protected_meta( array $meta ) {
$allowed = (array) apply_filters( 'emcp_tools_content_allowed_protected_meta', array() );
$allowed = self::get_allowed_protected_meta();
foreach ( array_keys( $meta ) as $key ) {
$key = (string) $key;
if ( in_array( $key, $allowed, true ) ) {
@@ -541,7 +587,14 @@ class EMCP_Tools_Content_Abilities {
'permalink' => array( 'type' => 'string' ),
'edit_link' => array( 'type' => 'string' ), 'author' => array( 'type' => 'object' ),
'terms' => array( 'type' => 'object' ), 'meta' => array( 'type' => 'object' ),
'featured_image' => array( 'type' => array( 'object', 'null' ) ),
'featured_image' => array(
'type' => 'object',
'properties' => array(
'id' => array( 'type' => 'integer' ),
'url' => array( 'type' => 'string' ),
'alt' => array( 'type' => 'string' ),
),
),
'is_elementor' => array( 'type' => 'boolean' ),
) ),
'meta' => array(
@@ -591,7 +644,7 @@ class EMCP_Tools_Content_Abilities {
$meta_raw = get_post_meta( $post_id );
$meta = array();
if ( is_array( $meta_raw ) ) {
$allowed = (array) apply_filters( 'emcp_tools_content_allowed_protected_meta', array() );
$allowed = self::get_allowed_protected_meta();
foreach ( $meta_raw as $key => $vals ) {
$key = (string) $key;
if ( ! in_array( $key, $allowed, true ) && ( '_' === substr( $key, 0, 1 ) || is_protected_meta( $key, 'post' ) ) ) {
@@ -606,7 +659,11 @@ class EMCP_Tools_Content_Abilities {
'id' => $thumb_id,
'url' => (string) wp_get_attachment_image_url( $thumb_id, 'full' ),
'alt' => (string) get_post_meta( $thumb_id, '_wp_attachment_image_alt', true ),
) : null;
) : array(
'id' => 0,
'url' => '',
'alt' => '',
);
$author_id = (int) ( $post->post_author ?? 0 );
$author_obj = $author_id ? get_userdata( $author_id ) : null;
@@ -92,10 +92,25 @@ class EMCP_Tools_Database_Abilities {
}
private function register_query(): void {
$this->ability( 'emcp-tools/query', __( 'Query (read-only)', 'emcp-tools' ), __( 'Run a read-only SQL query (SELECT/SHOW/DESCRIBE/EXPLAIN). Writes/DDL and file-access SQL are rejected. Results are capped.', 'emcp-tools' ), 'execute_query', array( 'sql' => array( 'type' => 'string' ), 'limit' => array( 'type' => 'integer' ) ), array( 'sql' ), true );
$this->ability(
'emcp-tools/query',
__( 'Query (read-only)', 'emcp-tools' ),
__( 'Run a read-only SQL query (SELECT/SHOW/DESCRIBE/EXPLAIN). Writes/DDL and file-access SQL are rejected. Results are capped.', 'emcp-tools' ),
'execute_query',
array(
'sql' => array( 'type' => 'string', 'description' => __( 'The SQL query to run.', 'emcp-tools' ) ),
'query' => array( 'type' => 'string', 'description' => __( 'Alias for sql.', 'emcp-tools' ) ),
'limit' => array( 'type' => 'integer', 'description' => __( 'Maximum rows to return.', 'emcp-tools' ) ),
),
array(),
true
);
}
public function execute_query( $input ) {
$sql = (string) ( $input['sql'] ?? '' );
$sql = trim( (string) ( $input['sql'] ?? $input['query'] ?? '' ) );
if ( '' === $sql ) {
return new \WP_Error( 'missing_sql', __( 'A "sql" (or "query") parameter is required.', 'emcp-tools' ) );
}
$ro = EMCP_Tools_Database_Guard::is_read_only_sql( $sql );
if ( is_wp_error( $ro ) ) {
return $ro;
+80 -1
View File
@@ -97,6 +97,37 @@ class EMCP_Tools_User_Abilities {
return false;
}
/**
* Checks whether a user meta key is forbidden (privilege escalation, auth secrets, roles).
*
* @since 3.4.3
* @param string $key
* @return bool
*/
public static function is_forbidden_user_meta_key( string $key ): bool {
$lower = strtolower( trim( $key ) );
if ( '' === $lower ) {
return true;
}
$forbidden_exact = array(
'capabilities',
'user_level',
'session_tokens',
'wp_user_level',
'default_password_nag',
'account_status',
);
if ( in_array( $lower, $forbidden_exact, true ) ) {
return true;
}
if ( preg_match( '/(^|_)capabilities$/i', $lower ) ||
preg_match( '/(^|_)user_level$/i', $lower ) ||
preg_match( '/(pass|token|secret|auth|nonce|activation|digest)/i', $lower ) ) {
return true;
}
return false;
}
// -------------------------------------------------------------------
// list-users
// -------------------------------------------------------------------
@@ -203,7 +234,10 @@ class EMCP_Tools_User_Abilities {
'permission_callback' => array( $this, 'can_list' ),
'input_schema' => array(
'type' => 'object',
'properties' => array( 'id' => array( 'type' => 'integer', 'description' => __( 'User ID.', 'emcp-tools' ) ) ),
'properties' => array(
'id' => array( 'type' => 'integer', 'description' => __( 'User ID.', 'emcp-tools' ) ),
'include_meta' => array( 'type' => 'boolean', 'description' => __( 'Whether to include safe user metadata. Default: false.', 'emcp-tools' ) ),
),
'required' => array( 'id' ),
),
'output_schema' => array( 'type' => 'object', 'properties' => array(
@@ -214,6 +248,7 @@ class EMCP_Tools_User_Abilities {
'description' => array( 'type' => 'string' ), 'roles' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
'registered' => array( 'type' => 'string' ), 'post_count' => array( 'type' => 'integer' ),
'is_admin' => array( 'type' => 'boolean' ),
'meta' => array( 'type' => 'object' ),
) ),
'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ), 'show_in_rest' => true ),
)
@@ -233,6 +268,21 @@ class EMCP_Tools_User_Abilities {
if ( ! $u ) {
return new \WP_Error( 'user_not_found', __( 'User not found.', 'emcp-tools' ) );
}
$meta = array();
if ( ! empty( $input['include_meta'] ) && function_exists( 'get_user_meta' ) ) {
$raw_meta = get_user_meta( $id );
if ( is_array( $raw_meta ) ) {
foreach ( $raw_meta as $mk => $mvals ) {
$mk = (string) $mk;
if ( self::is_forbidden_user_meta_key( $mk ) ) {
continue;
}
$meta[ $mk ] = is_array( $mvals ) && 1 === count( $mvals ) ? maybe_unserialize( $mvals[0] ) : array_map( 'maybe_unserialize', (array) $mvals );
}
}
}
return array(
'id' => (int) $u->ID,
'username' => (string) ( $u->user_login ?? '' ),
@@ -247,6 +297,7 @@ class EMCP_Tools_User_Abilities {
'registered' => (string) ( $u->user_registered ?? '' ),
'post_count' => function_exists( 'count_user_posts' ) ? (int) count_user_posts( $id ) : 0,
'is_admin' => $this->user_has_admin_caps( $id ),
'meta' => ! empty( $meta ) ? $meta : (object) array(),
);
}
@@ -275,6 +326,7 @@ class EMCP_Tools_User_Abilities {
'display_name' => array( 'type' => 'string' ),
'url' => array( 'type' => 'string' ),
'description' => array( 'type' => 'string' ),
'meta' => array( 'type' => 'object', 'description' => __( 'Optional key-value pairs of user meta to save.', 'emcp-tools' ) ),
),
'required' => array( 'username', 'email' ),
),
@@ -340,6 +392,16 @@ class EMCP_Tools_User_Abilities {
}
$user_id = (int) $user_id;
if ( isset( $input['meta'] ) && is_array( $input['meta'] ) && function_exists( 'update_user_meta' ) ) {
foreach ( $input['meta'] as $m_key => $m_val ) {
$m_key = sanitize_key( $m_key );
if ( self::is_forbidden_user_meta_key( $m_key ) ) {
continue;
}
update_user_meta( $user_id, $m_key, $m_val );
}
}
// Email the new user a set-password link. The password is NEVER returned.
if ( function_exists( 'wp_send_new_user_notifications' ) ) {
wp_send_new_user_notifications( $user_id, 'user' );
@@ -387,6 +449,7 @@ class EMCP_Tools_User_Abilities {
'nickname' => array( 'type' => 'string' ),
'url' => array( 'type' => 'string' ),
'description' => array( 'type' => 'string' ),
'meta' => array( 'type' => 'object', 'description' => __( 'Key-value pairs of user meta to save or update.', 'emcp-tools' ) ),
),
'required' => array( 'id' ),
),
@@ -443,6 +506,22 @@ class EMCP_Tools_User_Abilities {
$updated[] = 'email';
}
// Apply safe user metadata updates.
if ( isset( $input['meta'] ) && is_array( $input['meta'] ) && function_exists( 'update_user_meta' ) ) {
$meta_updated = false;
foreach ( $input['meta'] as $m_key => $m_val ) {
$m_key = sanitize_key( $m_key );
if ( self::is_forbidden_user_meta_key( $m_key ) ) {
continue;
}
update_user_meta( $id, $m_key, $m_val );
$meta_updated = true;
}
if ( $meta_updated ) {
$updated[] = 'meta';
}
}
// Capture prior values of exactly the fields being changed, for rollback.
$emcp_before = array();
if ( class_exists( 'EMCP_Tools_Change_Recorder' ) ) {
+155 -28
View File
@@ -98,6 +98,7 @@ class EMCP_Tools_Woo_Integration {
'get-order' => 'Get order by { order_id }',
'list-customers' => 'List registered customers { limit? }',
'list-coupons' => 'List coupons { limit? }',
'list-vendors' => 'List marketplace vendors / models { role?, limit? }',
'report-sales' => 'Report sales { period?: "week"|"month"|"year" }',
'system-status' => 'WooCommerce system health and environment status',
),
@@ -114,11 +115,16 @@ class EMCP_Tools_Woo_Integration {
);
$items = array();
foreach ( $posts as $p ) {
$items[] = array(
'id' => $p->ID,
'name' => $p->post_title,
'sku' => get_post_meta( $p->ID, '_sku', true ),
'price' => get_post_meta( $p->ID, '_price', true ),
$thumb_id = (int) get_post_thumbnail_id( $p->ID );
$items[] = array(
'id' => (int) $p->ID,
'name' => (string) $p->post_title,
'sku' => (string) get_post_meta( $p->ID, '_sku', true ),
'price' => (string) get_post_meta( $p->ID, '_price', true ),
'regular_price' => (string) get_post_meta( $p->ID, '_regular_price', true ),
'author' => (int) ( $p->post_author ?? 0 ),
'virtual' => 'yes' === get_post_meta( $p->ID, '_virtual', true ),
'featured_image' => $thumb_id,
);
}
return array( 'products' => $items, 'total' => count( $items ) );
@@ -129,15 +135,40 @@ class EMCP_Tools_Woo_Integration {
if ( ! $p || 'product' !== $p->post_type ) {
return new WP_Error( 'not_found', __( 'Product not found.', 'emcp-tools' ) );
}
$thumb_id = (int) get_post_thumbnail_id( $p->ID );
return array(
'id' => $p->ID,
'name' => $p->post_title,
'description' => $p->post_content,
'sku' => get_post_meta( $p->ID, '_sku', true ),
'price' => get_post_meta( $p->ID, '_price', true ),
'stock' => get_post_meta( $p->ID, '_stock', true ),
'id' => (int) $p->ID,
'name' => (string) $p->post_title,
'description' => (string) $p->post_content,
'author' => (int) ( $p->post_author ?? 0 ),
'status' => (string) $p->post_status,
'sku' => (string) get_post_meta( $p->ID, '_sku', true ),
'price' => (string) get_post_meta( $p->ID, '_price', true ),
'regular_price' => (string) get_post_meta( $p->ID, '_regular_price', true ),
'sale_price' => (string) get_post_meta( $p->ID, '_sale_price', true ),
'stock' => get_post_meta( $p->ID, '_stock', true ),
'virtual' => 'yes' === get_post_meta( $p->ID, '_virtual', true ),
'downloadable' => 'yes' === get_post_meta( $p->ID, '_downloadable', true ),
'thumbnail_id' => $thumb_id,
'permalink' => (string) get_permalink( $id ),
);
case 'list-vendors':
$role = sanitize_key( $in['role'] ?? 'wcfm_vendor' );
$users = get_users( array( 'role' => $role, 'number' => (int) ( $in['limit'] ?? 50 ) ) );
$vendors = array();
foreach ( $users as $u ) {
$store_settings = get_user_meta( $u->ID, 'wcfmmp_profile_settings', true );
$store_name = is_array( $store_settings ) && ! empty( $store_settings['store_name'] ) ? $store_settings['store_name'] : $u->display_name;
$vendors[] = array(
'id' => (int) $u->ID,
'username' => (string) $u->user_login,
'store_name' => (string) $store_name,
'email' => (string) $u->user_email,
);
}
return array( 'vendors' => $vendors, 'total' => count( $vendors ) );
case 'list-orders':
$posts = get_posts(
array(
@@ -199,41 +230,137 @@ class EMCP_Tools_Woo_Integration {
switch ( $op ) {
case 'create-product':
$title = sanitize_text_field( $in['name'] ?? 'New Product' );
$id = wp_insert_post(
array(
'post_type' => 'product',
'post_status' => 'publish',
'post_title' => $title,
'post_content'=> (string) ( $in['description'] ?? '' ),
)
$title = sanitize_text_field( $in['name'] ?? $in['title'] ?? 'New Product' );
$post_data = array(
'post_type' => 'product',
'post_status' => sanitize_key( $in['status'] ?? 'publish' ),
'post_title' => $title,
'post_content' => (string) ( $in['description'] ?? $in['content'] ?? '' ),
);
if ( ! empty( $in['author'] ) || ! empty( $in['vendor_id'] ) ) {
$post_data['post_author'] = absint( $in['author'] ?? $in['vendor_id'] );
}
$id = wp_insert_post( $post_data );
if ( is_wp_error( $id ) ) {
return $id;
}
if ( isset( $in['price'] ) ) {
update_post_meta( $id, '_price', sanitize_text_field( $in['price'] ) );
update_post_meta( $id, '_regular_price', sanitize_text_field( $in['price'] ) );
update_post_meta( $id, '_price', sanitize_text_field( (string) $in['price'] ) );
update_post_meta( $id, '_regular_price', sanitize_text_field( (string) $in['price'] ) );
}
if ( isset( $in['regular_price'] ) ) {
update_post_meta( $id, '_regular_price', sanitize_text_field( (string) $in['regular_price'] ) );
if ( ! isset( $in['price'] ) ) {
update_post_meta( $id, '_price', sanitize_text_field( (string) $in['regular_price'] ) );
}
}
if ( isset( $in['sale_price'] ) ) {
update_post_meta( $id, '_sale_price', sanitize_text_field( (string) $in['sale_price'] ) );
if ( '' !== trim( (string) $in['sale_price'] ) ) {
update_post_meta( $id, '_price', sanitize_text_field( (string) $in['sale_price'] ) );
}
}
if ( isset( $in['sku'] ) ) {
update_post_meta( $id, '_sku', sanitize_text_field( $in['sku'] ) );
update_post_meta( $id, '_sku', sanitize_text_field( (string) $in['sku'] ) );
}
return array( 'success' => true, 'product_id' => $id );
if ( isset( $in['virtual'] ) ) {
$is_virtual = ( true === $in['virtual'] || 'yes' === $in['virtual'] || '1' === (string) $in['virtual'] ) ? 'yes' : 'no';
update_post_meta( $id, '_virtual', $is_virtual );
}
if ( isset( $in['downloadable'] ) ) {
$is_dl = ( true === $in['downloadable'] || 'yes' === $in['downloadable'] || '1' === (string) $in['downloadable'] ) ? 'yes' : 'no';
update_post_meta( $id, '_downloadable', $is_dl );
}
if ( ! empty( $in['featured_image'] ) || ! empty( $in['thumbnail_id'] ) ) {
$thumb_id = absint( $in['featured_image'] ?? $in['thumbnail_id'] );
if ( $thumb_id && function_exists( 'set_post_thumbnail' ) ) {
set_post_thumbnail( $id, $thumb_id );
}
}
if ( ! empty( $in['categories'] ) && function_exists( 'wp_set_object_terms' ) ) {
wp_set_object_terms( $id, (array) $in['categories'], 'product_cat' );
}
if ( ! empty( $in['tags'] ) && function_exists( 'wp_set_object_terms' ) ) {
wp_set_object_terms( $id, (array) $in['tags'], 'product_tag' );
}
if ( isset( $in['meta'] ) && is_array( $in['meta'] ) ) {
foreach ( $in['meta'] as $mk => $mv ) {
update_post_meta( $id, sanitize_key( $mk ), $mv );
}
}
return array(
'success' => true,
'product_id' => $id,
'edit_link' => admin_url( 'post.php?post=' . $id . '&action=edit' ),
);
case 'update-product':
$id = (int) ( $in['product_id'] ?? 0 );
if ( ! $id ) {
return new WP_Error( 'missing_id', __( 'product_id required.', 'emcp-tools' ) );
}
if ( isset( $in['name'] ) ) {
wp_update_post( array( 'ID' => $id, 'post_title' => sanitize_text_field( $in['name'] ) ) );
$post_update = array( 'ID' => $id );
if ( isset( $in['name'] ) || isset( $in['title'] ) ) {
$post_update['post_title'] = sanitize_text_field( $in['name'] ?? $in['title'] );
}
if ( isset( $in['description'] ) || isset( $in['content'] ) ) {
$post_update['post_content'] = (string) ( $in['description'] ?? $in['content'] );
}
if ( isset( $in['status'] ) ) {
$post_update['post_status'] = sanitize_key( $in['status'] );
}
if ( ! empty( $in['author'] ) || ! empty( $in['vendor_id'] ) ) {
$post_update['post_author'] = absint( $in['author'] ?? $in['vendor_id'] );
}
if ( count( $post_update ) > 1 ) {
wp_update_post( $post_update );
}
if ( isset( $in['price'] ) ) {
update_post_meta( $id, '_price', sanitize_text_field( $in['price'] ) );
update_post_meta( $id, '_regular_price', sanitize_text_field( $in['price'] ) );
update_post_meta( $id, '_price', sanitize_text_field( (string) $in['price'] ) );
update_post_meta( $id, '_regular_price', sanitize_text_field( (string) $in['price'] ) );
}
if ( isset( $in['regular_price'] ) ) {
update_post_meta( $id, '_regular_price', sanitize_text_field( (string) $in['regular_price'] ) );
if ( ! isset( $in['price'] ) ) {
update_post_meta( $id, '_price', sanitize_text_field( (string) $in['regular_price'] ) );
}
}
if ( isset( $in['sale_price'] ) ) {
update_post_meta( $id, '_sale_price', sanitize_text_field( (string) $in['sale_price'] ) );
if ( '' !== trim( (string) $in['sale_price'] ) ) {
update_post_meta( $id, '_price', sanitize_text_field( (string) $in['sale_price'] ) );
}
}
if ( isset( $in['sku'] ) ) {
update_post_meta( $id, '_sku', sanitize_text_field( $in['sku'] ) );
update_post_meta( $id, '_sku', sanitize_text_field( (string) $in['sku'] ) );
}
if ( isset( $in['virtual'] ) ) {
$is_virtual = ( true === $in['virtual'] || 'yes' === $in['virtual'] || '1' === (string) $in['virtual'] ) ? 'yes' : 'no';
update_post_meta( $id, '_virtual', $is_virtual );
}
if ( isset( $in['downloadable'] ) ) {
$is_dl = ( true === $in['downloadable'] || 'yes' === $in['downloadable'] || '1' === (string) $in['downloadable'] ) ? 'yes' : 'no';
update_post_meta( $id, '_downloadable', $is_dl );
}
if ( isset( $in['featured_image'] ) || isset( $in['thumbnail_id'] ) ) {
$thumb_id = absint( $in['featured_image'] ?? $in['thumbnail_id'] );
if ( $thumb_id && function_exists( 'set_post_thumbnail' ) ) {
set_post_thumbnail( $id, $thumb_id );
} elseif ( 0 === $thumb_id && function_exists( 'delete_post_thumbnail' ) ) {
delete_post_thumbnail( $id );
}
}
if ( isset( $in['categories'] ) && function_exists( 'wp_set_object_terms' ) ) {
wp_set_object_terms( $id, (array) $in['categories'], 'product_cat' );
}
if ( isset( $in['tags'] ) && function_exists( 'wp_set_object_terms' ) ) {
wp_set_object_terms( $id, (array) $in['tags'], 'product_tag' );
}
if ( isset( $in['meta'] ) && is_array( $in['meta'] ) ) {
foreach ( $in['meta'] as $mk => $mv ) {
update_post_meta( $id, sanitize_key( $mk ), $mv );
}
}
return array( 'success' => true, 'product_id' => $id );
+15 -9
View File
@@ -195,18 +195,24 @@ class EMCP_Tools_Schema_Compat {
// runs. Picking the first non-null entry is the documented "lossy" way to
// express the union without losing the schema altogether. Callers that need
// both types should coerce at the executor (see `set-post-terms`).
// When 'string' is in a non-null union (e.g. ['integer', 'string']), prefer
// 'string' so term names and identifiers are both accepted without validator rejection.
if ( isset( $schema['type'] ) && is_array( $schema['type'] ) ) {
$flattened = null;
foreach ( $schema['type'] as $candidate ) {
if ( is_string( $candidate ) && 'null' !== $candidate ) {
$flattened = $candidate;
break;
if ( in_array( 'string', $schema['type'], true ) && ! in_array( 'null', $schema['type'], true ) ) {
$schema['type'] = 'string';
} else {
$flattened = null;
foreach ( $schema['type'] as $candidate ) {
if ( is_string( $candidate ) && 'null' !== $candidate ) {
$flattened = $candidate;
break;
}
}
if ( null === $flattened ) {
$flattened = 'string';
}
$schema['type'] = $flattened;
}
if ( null === $flattened ) {
$flattened = 'string';
}
$schema['type'] = $flattened;
}
if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* Tests Content Abilities e-commerce meta and featured_image handling.
*
* @package EMCP_Tools
*/
require_once dirname( __DIR__ ) . '/includes/abilities/class-content-abilities.php';
class ContentAbilitiesMetaTest extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
parent::setUp();
emcp_test_reset();
}
public function test_allowed_protected_meta_contains_woocommerce_keys(): void {
$allowed = EMCP_Tools_Content_Abilities::get_allowed_protected_meta();
$this->assertIsArray( $allowed );
$expected_keys = array(
'_price',
'_regular_price',
'_sale_price',
'_virtual',
'_downloadable',
'_sku',
'_stock_status',
'_stock',
'_thumbnail_id',
'_pay_per_post_price',
'_wp_page_template',
);
foreach ( $expected_keys as $key ) {
$this->assertContains( $key, $allowed, "Expected '{$key}' to be included in default allowed protected meta" );
}
// Ensure dangerous internal Elementor keys are NOT in allowed list
$this->assertNotContains( '_elementor_data', $allowed );
$this->assertNotContains( '_elementor_css', $allowed );
}
public function test_format_post_featured_image_without_thumbnail_returns_object_structure(): void {
$abilities = new EMCP_Tools_Content_Abilities();
$post = new \WP_Post( array(
'ID' => 42,
'post_title' => 'Sample Post Without Thumbnail',
'post_name' => 'sample-post',
'post_status' => 'publish',
'post_content' => 'Some content',
'post_excerpt' => '',
'post_date' => '2026-09-12 12:00:00',
'post_type' => 'post',
'post_author' => 1,
'comment_status' => 'closed',
) );
$ref = new \ReflectionClass( $abilities );
$method = $ref->getMethod( 'format_post' );
$formatted = $method->invoke( $abilities, $post );
$this->assertIsArray( $formatted );
$this->assertArrayHasKey( 'featured_image', $formatted );
$this->assertIsArray( $formatted['featured_image'] );
$this->assertSame( 0, $formatted['featured_image']['id'] );
$this->assertSame( '', $formatted['featured_image']['url'] );
$this->assertSame( '', $formatted['featured_image']['alt'] );
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* Tests Database Abilities query execution with sql and query argument aliases.
*
* @package EMCP_Tools
*/
require_once dirname( __DIR__ ) . '/includes/sql/class-sql-lexer.php';
require_once dirname( __DIR__ ) . '/includes/sql/class-sql-policy.php';
require_once dirname( __DIR__ ) . '/includes/class-database-guard.php';
require_once dirname( __DIR__ ) . '/includes/abilities/class-database-abilities.php';
class DatabaseAbilitiesQueryTest extends \PHPUnit\Framework\TestCase {
private EMCP_Tools_Database_Abilities $db_abilities;
protected function setUp(): void {
parent::setUp();
emcp_test_reset();
$this->db_abilities = new EMCP_Tools_Database_Abilities();
}
public function test_missing_sql_and_query_fails_cleanly(): void {
$res = $this->db_abilities->execute_query( array() );
$this->assertInstanceOf( \WP_Error::class, $res );
$this->assertSame( 'missing_sql', $res->get_error_code() );
$res2 = $this->db_abilities->execute_query( array( 'sql' => ' ', 'query' => '' ) );
$this->assertInstanceOf( \WP_Error::class, $res2 );
$this->assertSame( 'missing_sql', $res2->get_error_code() );
}
public function test_write_rejected_under_query_alias(): void {
$res = $this->db_abilities->execute_query( array(
'query' => "UPDATE wp_posts SET post_title = 'hacked'",
) );
$this->assertInstanceOf( \WP_Error::class, $res );
$this->assertSame( 'not_read_only', $res->get_error_code() );
}
public function test_user_table_protected_read_under_query_alias(): void {
$res = $this->db_abilities->execute_query( array(
'query' => "SELECT user_pass FROM wp_users",
) );
$this->assertInstanceOf( \WP_Error::class, $res );
$this->assertSame( 'protected_read', $res->get_error_code() );
}
public function test_sql_and_query_aliases_behave_identically(): void {
$res_sql = $this->db_abilities->execute_query( array(
'sql' => "DROP TABLE wp_posts",
) );
$res_query = $this->db_abilities->execute_query( array(
'query' => "DROP TABLE wp_posts",
) );
$this->assertInstanceOf( \WP_Error::class, $res_sql );
$this->assertInstanceOf( \WP_Error::class, $res_query );
$this->assertSame( $res_sql->get_error_code(), $res_query->get_error_code() );
$this->assertSame( 'not_read_only', $res_sql->get_error_code() );
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* Tests User Abilities (create-user, update-user, get-user) with meta support.
*
* @package EMCP_Tools
*/
require_once dirname( __DIR__ ) . '/includes/abilities/class-user-abilities.php';
class UserAbilitiesTest extends \PHPUnit\Framework\TestCase {
private EMCP_Tools_User_Abilities $abilities;
protected function setUp(): void {
parent::setUp();
emcp_test_reset();
$this->abilities = new EMCP_Tools_User_Abilities();
}
public function test_forbidden_meta_keys_blocked(): void {
$blocked = array(
'capabilities',
'wp_capabilities',
'user_level',
'wp_user_level',
'session_tokens',
'default_password_nag',
'user_pass',
'auth_token',
'secret_key',
'activation_key',
'nonce_field',
'password_reset',
);
foreach ( $blocked as $key ) {
$this->assertTrue(
EMCP_Tools_User_Abilities::is_forbidden_user_meta_key( $key ),
"Expected key '{$key}' to be forbidden"
);
}
}
public function test_safe_vendor_and_profile_meta_keys_allowed(): void {
$allowed = array(
'wcfmmp_profile_settings',
'store_name',
'phone',
'twitter',
'instagram',
'payout_paypal',
'creator_tier',
'billing_address_1',
'shipping_city',
'model_bio',
);
foreach ( $allowed as $key ) {
$this->assertFalse(
EMCP_Tools_User_Abilities::is_forbidden_user_meta_key( $key ),
"Expected key '{$key}' to be permitted"
);
}
}
public function test_create_user_with_safe_meta(): void {
$input = array(
'username' => 'testvendor',
'email' => '[email protected]',
'role' => 'wcfm_vendor',
'meta' => array(
'store_name' => 'Jackie Studio',
'creator_tier' => 'vip',
'capabilities' => 'hacked', // Forbidden!
),
);
$result = $this->abilities->execute_create_user( $input );
$this->assertIsArray( $result );
$this->assertArrayHasKey( 'id', $result );
$id = $result['id'];
// Safe meta saved
$this->assertSame( 'Jackie Studio', get_user_meta( $id, 'store_name', true ) );
$this->assertSame( 'vip', get_user_meta( $id, 'creator_tier', true ) );
// Forbidden meta dropped
$this->assertSame( '', get_user_meta( $id, 'capabilities', true ) );
}
public function test_update_user_with_safe_meta(): void {
$user_id = wp_insert_user( array(
'user_login' => 'model1',
'user_email' => '[email protected]',
'role' => 'wcfm_vendor',
) );
$input = array(
'id' => $user_id,
'meta' => array(
'twitter' => 'https://x.com/model1',
'user_pass' => 'injected', // Forbidden!
),
);
$res = $this->abilities->execute_update_user( $input );
$this->assertIsArray( $res );
$this->assertContains( 'meta', $res['updated'] );
// Safe meta saved
$this->assertSame( 'https://x.com/model1', get_user_meta( $user_id, 'twitter', true ) );
// Forbidden meta dropped
$this->assertSame( '', get_user_meta( $user_id, 'user_pass', true ) );
}
public function test_get_user_with_include_meta(): void {
$user_id = wp_insert_user( array(
'user_login' => 'model2',
'user_email' => '[email protected]',
'role' => 'wcfm_vendor',
) );
update_user_meta( $user_id, 'store_name', 'Model 2 Store' );
update_user_meta( $user_id, 'session_tokens', 'supersecret' );
// Without include_meta
$without = $this->abilities->execute_get_user( array( 'id' => $user_id ) );
$this->assertIsArray( $without );
$this->assertEquals( (object) array(), $without['meta'] );
// With include_meta
$with = $this->abilities->execute_get_user( array( 'id' => $user_id, 'include_meta' => true ) );
$this->assertIsArray( $with );
$this->assertArrayHasKey( 'meta', $with );
$this->assertArrayHasKey( 'store_name', $with['meta'] );
$this->assertSame( 'Model 2 Store', $with['meta']['store_name'] );
// Sensitive session_tokens redacted
$this->assertArrayNotHasKey( 'session_tokens', $with['meta'] );
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* Tests WooCommerce and Marketplace Creator Integration.
*
* @package EMCP_Tools
*/
require_once dirname( __DIR__ ) . '/includes/abilities/class-woo-integration.php';
class WooIntegrationTest extends \PHPUnit\Framework\TestCase {
private EMCP_Tools_Woo_Integration $woo;
protected function setUp(): void {
parent::setUp();
emcp_test_reset();
$this->woo = new EMCP_Tools_Woo_Integration();
}
public function test_catalog_operations_includes_list_vendors(): void {
$catalog = $this->woo->execute_read( array( 'operation' => '' ) );
$this->assertIsArray( $catalog );
$this->assertArrayHasKey( 'operations', $catalog );
$this->assertArrayHasKey( 'list-vendors', $catalog['operations'] );
$this->assertArrayHasKey( 'list-products', $catalog['operations'] );
}
public function test_create_product_with_vendor_and_virtual(): void {
// Mock wp_insert_post if needed, or check stub
if ( ! function_exists( 'wp_insert_post' ) ) {
// define stub
}
$input = array(
'operation' => 'create-product',
'arguments' => array(
'name' => 'VIP Access Pass',
'price' => '14.99',
'regular_price' => '14.99',
'sale_price' => '9.99',
'sku' => 'VIP-PASS-01',
'author' => 2,
'virtual' => true,
'downloadable' => true,
'meta' => array(
'custom_perk' => 'exclusive_vods',
),
),
);
$res = $this->woo->execute_write( $input );
$this->assertIsArray( $res );
$this->assertTrue( $res['success'] );
$product_id = $res['product_id'];
$this->assertSame( '9.99', get_post_meta( $product_id, '_price', true ) );
$this->assertSame( '14.99', get_post_meta( $product_id, '_regular_price', true ) );
$this->assertSame( '9.99', get_post_meta( $product_id, '_sale_price', true ) );
$this->assertSame( 'VIP-PASS-01', get_post_meta( $product_id, '_sku', true ) );
$this->assertSame( 'yes', get_post_meta( $product_id, '_virtual', true ) );
$this->assertSame( 'yes', get_post_meta( $product_id, '_downloadable', true ) );
$this->assertSame( 'exclusive_vods', get_post_meta( $product_id, 'custom_perk', true ) );
$post = get_post( $product_id );
$this->assertNotNull( $post );
$this->assertSame( 2, (int) $post->post_author );
}
public function test_list_vendors_returns_models(): void {
$user_id = wp_insert_user( array(
'user_login' => 'jackie12358',
'user_email' => '[email protected]',
'display_name' => 'Jackie Trans Goddess',
'role' => 'wcfm_vendor',
) );
update_user_meta( $user_id, 'wcfmmp_profile_settings', array(
'store_name' => 'Jackie VIP Studio',
) );
// Stub get_users if not stubbed
$input = array(
'operation' => 'list-vendors',
'arguments' => array( 'role' => 'wcfm_vendor' ),
);
$res = $this->woo->execute_read( $input );
$this->assertIsArray( $res );
$this->assertArrayHasKey( 'vendors', $res );
$this->assertCount( 1, $res['vendors'] );
$this->assertSame( 'jackie12358', $res['vendors'][0]['username'] );
$this->assertSame( 'Jackie VIP Studio', $res['vendors'][0]['store_name'] );
}
}
+271
View File
@@ -43,6 +43,14 @@ function emcp_test_reset(): void {
'options_pages' => array(),
'abilities' => array(), // name => registration args.
'options' => array(), // option name => value (get_option/update_option).
'users' => array(), // user_id => user object.
'usermeta' => array(), // user_id => [ key => val ].
'user_caps' => array(), // user_id => [ cap => bool ].
'roles' => array(
'subscriber' => (object) array( 'name' => 'Subscriber', 'capabilities' => array( 'read' => true ) ),
'wcfm_vendor' => (object) array( 'name' => 'Vendor', 'capabilities' => array( 'read' => true, 'edit_posts' => true ) ),
'administrator' => (object) array( 'name' => 'Administrator', 'capabilities' => array( 'manage_options' => true, 'edit_users' => true ) ),
),
'cpt_tax_supported' => true, // Toggles the ACF 6.1+ CPT/tax API stubs.
'acf_post_types' => array(), // key/ID => acf-post-type definition.
'acf_taxonomies' => array(), // key/ID => acf-taxonomy definition.
@@ -52,6 +60,13 @@ function emcp_test_reset(): void {
'imported_taxes' => array(), // recorded acf_import_taxonomy() args.
'updated_internal' => array(), // recorded acf_update_internal_post_type() args.
);
$GLOBALS['wpdb'] = (object) array(
'prefix' => 'wp_',
'users' => 'wp_users',
'usermeta' => 'wp_usermeta',
'posts' => 'wp_posts',
'postmeta' => 'wp_postmeta',
);
}
emcp_test_reset();
@@ -153,6 +168,79 @@ function get_post( $post_id ) {
return $GLOBALS['emcp_test']['posts'][ (int) $post_id ] ?? null;
}
if ( ! function_exists( 'wp_insert_post' ) ) {
function wp_insert_post( $postarr, $wp_error = false ) {
static $next_post_id = 500;
$id = ++$next_post_id;
$obj = new WP_Post( array_merge( array(
'ID' => $id,
'post_title' => $postarr['post_title'] ?? '',
'post_content' => $postarr['post_content'] ?? '',
'post_status' => $postarr['post_status'] ?? 'publish',
'post_type' => $postarr['post_type'] ?? 'post',
'post_author' => $postarr['post_author'] ?? 0,
'post_date' => date( 'Y-m-d H:i:s' ),
), (array) $postarr ) );
$GLOBALS['emcp_test']['posts'][ $id ] = $obj;
return $id;
}
}
if ( ! function_exists( 'wp_update_post' ) ) {
function wp_update_post( $postarr, $wp_error = false ) {
$id = (int) ( is_array( $postarr ) ? ( $postarr['ID'] ?? 0 ) : ( $postarr->ID ?? 0 ) );
if ( ! isset( $GLOBALS['emcp_test']['posts'][ $id ] ) ) {
return 0;
}
$existing = (array) $GLOBALS['emcp_test']['posts'][ $id ];
$GLOBALS['emcp_test']['posts'][ $id ] = new WP_Post( array_merge( $existing, (array) $postarr ) );
return $id;
}
}
if ( ! function_exists( 'get_users' ) ) {
function get_users( $args = array() ) {
$role = $args['role'] ?? '';
$out = array();
foreach ( (array) ( $GLOBALS['emcp_test']['users'] ?? array() ) as $u ) {
if ( '' === $role || ( ! empty( $u->roles ) && in_array( $role, (array) $u->roles, true ) ) ) {
$out[] = $u;
}
}
return $out;
}
}
if ( ! function_exists( 'get_post_meta' ) ) {
function get_post_meta( $post_id, $key = '', $single = false ) {
$post_id = (int) $post_id;
$store = $GLOBALS['emcp_test']['postmeta'][ $post_id ] ?? array();
if ( '' === $key ) {
$out = array();
foreach ( $store as $k => $v ) {
$out[ $k ] = (array) $v;
}
return $out;
}
if ( ! isset( $store[ $key ] ) ) {
return $single ? '' : array();
}
$val = $store[ $key ];
return $single ? $val : (array) $val;
}
}
if ( ! function_exists( 'update_post_meta' ) ) {
function update_post_meta( $post_id, $key, $value ) {
$post_id = (int) $post_id;
if ( ! isset( $GLOBALS['emcp_test']['postmeta'][ $post_id ] ) ) {
$GLOBALS['emcp_test']['postmeta'][ $post_id ] = array();
}
$GLOBALS['emcp_test']['postmeta'][ $post_id ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'delete_post_meta' ) ) {
// Records deletions in $GLOBALS['emcp_test']['deleted_meta'] as [post_id, key].
function delete_post_meta( $post_id, $key, $value = '' ) {
@@ -165,6 +253,37 @@ function get_permalink( $post = null ): string {
return 'http://example.test/?p=' . ( is_object( $post ) ? (int) $post->ID : (int) $post );
}
if ( ! function_exists( 'get_object_taxonomies' ) ) {
function get_object_taxonomies( $object, $output = 'names' ) {
return array( 'category', 'post_tag' );
}
}
if ( ! function_exists( 'get_the_terms' ) ) {
function get_the_terms( $post, $taxonomy ) {
return false;
}
}
if ( ! function_exists( 'get_post_thumbnail_id' ) ) {
function get_post_thumbnail_id( $post = null ) {
$post_id = is_object( $post ) ? (int) ( $post->ID ?? 0 ) : (int) $post;
return (int) get_post_meta( $post_id, '_thumbnail_id', true );
}
}
if ( ! function_exists( 'wp_get_attachment_image_url' ) ) {
function wp_get_attachment_image_url( $attachment_id, $size = 'thumbnail', $icon = false ) {
return $attachment_id ? "https://example.com/wp-content/uploads/{$attachment_id}.jpg" : false;
}
}
if ( ! function_exists( 'is_protected_meta' ) ) {
function is_protected_meta( $meta_key, $meta_type = null ) {
return '_' === substr( (string) $meta_key, 0, 1 );
}
}
function admin_url( $path = '' ): string {
return 'http://example.test/wp-admin/' . $path;
}
@@ -198,6 +317,158 @@ function get_post_status( $id ) {
return $GLOBALS['emcp_test']['post_status'][ (int) $id ] ?? false;
}
// ---------------------------------------------------------------------------
// WordPress User and Meta stubs (fixture-driven)
// ---------------------------------------------------------------------------
if ( ! function_exists( 'get_userdata' ) ) {
function get_userdata( $id ) {
$u = $GLOBALS['emcp_test']['users'][ (int) $id ] ?? null;
return $u ? ( is_object( $u ) ? $u : (object) $u ) : false;
}
}
if ( ! function_exists( 'get_user_meta' ) ) {
function get_user_meta( $user_id, $key = '', $single = false ) {
$user_id = (int) $user_id;
$store = $GLOBALS['emcp_test']['usermeta'][ $user_id ] ?? array();
if ( '' === $key ) {
$out = array();
foreach ( $store as $k => $v ) {
$out[ $k ] = (array) $v;
}
return $out;
}
if ( ! isset( $store[ $key ] ) ) {
return $single ? '' : array();
}
$val = $store[ $key ];
return $single ? $val : (array) $val;
}
}
if ( ! function_exists( 'update_user_meta' ) ) {
function update_user_meta( $user_id, $key, $value ) {
$user_id = (int) $user_id;
if ( ! isset( $GLOBALS['emcp_test']['usermeta'][ $user_id ] ) ) {
$GLOBALS['emcp_test']['usermeta'][ $user_id ] = array();
}
$GLOBALS['emcp_test']['usermeta'][ $user_id ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'wp_insert_user' ) ) {
function wp_insert_user( $userdata ) {
static $next_id = 100;
$id = ++$next_id;
$obj = (object) array_merge( array(
'ID' => $id,
'user_login' => $userdata['user_login'] ?? '',
'user_email' => $userdata['user_email'] ?? '',
'display_name' => $userdata['display_name'] ?? $userdata['user_login'] ?? '',
'first_name' => $userdata['first_name'] ?? '',
'last_name' => $userdata['last_name'] ?? '',
'user_url' => $userdata['user_url'] ?? '',
'description' => $userdata['description'] ?? '',
'roles' => array( $userdata['role'] ?? 'subscriber' ),
'user_registered' => date( 'Y-m-d H:i:s' ),
), (array) $userdata );
$GLOBALS['emcp_test']['users'][ $id ] = $obj;
return $id;
}
}
if ( ! function_exists( 'wp_update_user' ) ) {
function wp_update_user( $userdata ) {
$id = (int) ( is_array( $userdata ) ? ( $userdata['ID'] ?? 0 ) : ( $userdata->ID ?? 0 ) );
if ( ! isset( $GLOBALS['emcp_test']['users'][ $id ] ) ) {
return new WP_Error( 'invalid_user_id', 'User not found' );
}
$curr = (array) $GLOBALS['emcp_test']['users'][ $id ];
$GLOBALS['emcp_test']['users'][ $id ] = (object) array_merge( $curr, (array) $userdata );
return $id;
}
}
if ( ! function_exists( 'user_can' ) ) {
function user_can( $user, $capability ) {
$user_id = is_object( $user ) ? (int) ( $user->ID ?? 0 ) : (int) $user;
if ( isset( $GLOBALS['emcp_test']['user_caps'][ $user_id ][ $capability ] ) ) {
return (bool) $GLOBALS['emcp_test']['user_caps'][ $user_id ][ $capability ];
}
$u = $GLOBALS['emcp_test']['users'][ $user_id ] ?? null;
if ( $u && ! empty( $u->roles ) && in_array( 'administrator', (array) $u->roles, true ) ) {
return true;
}
return false;
}
}
if ( ! function_exists( 'get_role' ) ) {
function get_role( $role ) {
return $GLOBALS['emcp_test']['roles'][ $role ] ?? null;
}
}
if ( ! function_exists( 'sanitize_user' ) ) {
function sanitize_user( $username, $strict = false ) {
return preg_replace( '/[^a-zA-Z0-9_\-\.]/', '', (string) $username );
}
}
if ( ! function_exists( 'sanitize_email' ) ) {
function sanitize_email( $email ) {
return filter_var( (string) $email, FILTER_SANITIZE_EMAIL ) ?: '';
}
}
if ( ! function_exists( 'is_email' ) ) {
function is_email( $email ) {
return (bool) filter_var( (string) $email, FILTER_VALIDATE_EMAIL );
}
}
if ( ! function_exists( 'wp_generate_password' ) ) {
function wp_generate_password( $length = 12, $special_chars = true, $extra_special_chars = false ) {
return bin2hex( random_bytes( max( 1, (int) ( $length / 2 ) ) ) );
}
}
if ( ! function_exists( 'maybe_unserialize' ) ) {
function maybe_unserialize( $original ) {
if ( is_string( $original ) && is_serialized( $original ) ) {
return @unserialize( $original );
}
return $original;
}
}
if ( ! function_exists( 'is_serialized' ) ) {
function is_serialized( $data, $strict = true ) {
if ( ! is_string( $data ) ) {
return false;
}
$data = trim( $data );
if ( 'N;' === $data ) {
return true;
}
if ( strlen( $data ) < 4 ) {
return false;
}
if ( ':' !== $data[1] ) {
return false;
}
return true;
}
}
if ( ! function_exists( 'count_user_posts' ) ) {
function count_user_posts( $userid, $post_type = 'post', $public_only = false ) {
return 0;
}
}
// ---------------------------------------------------------------------------
// ACF stubs (fixture-driven)
// ---------------------------------------------------------------------------