diff --git a/projects/packages/forms/changelog/add-forms-preview-submissions b/projects/packages/forms/changelog/add-forms-preview-submissions new file mode 100644 index 000000000000..ec29171d25b2 --- /dev/null +++ b/projects/packages/forms/changelog/add-forms-preview-submissions @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Form preview now lets you submit the form to test the full submission flow end to end. Responses created from preview are stored as test responses, clearly flagged in the notification email, and excluded from the default CSV export. diff --git a/projects/packages/forms/routes/responses/stage.tsx b/projects/packages/forms/routes/responses/stage.tsx index 8ec99baf6919..717c4c9933ef 100644 --- a/projects/packages/forms/routes/responses/stage.tsx +++ b/projects/packages/forms/routes/responses/stage.tsx @@ -59,6 +59,10 @@ type FeedbackFilters = { const EMPTY_ARRAY = []; +// Sentinel value used in the Source filter to represent form-preview (test) responses. +// Source IDs are numeric post IDs, so this non-numeric value is safe from collision. +const FORM_PREVIEW_SOURCE_VALUE = 'form_preview'; + const defaultLayouts = { table: {}, list: {}, @@ -71,6 +75,7 @@ type QueryParams = { orderby?: string; order?: string; is_unread?: boolean; + is_test?: boolean; parent?: string; source?: string; before?: string; @@ -309,7 +314,11 @@ function StageInner() { queryArgs.is_unread = filter.value === 'unread'; } if ( ! isSingleFormView && filter.field === 'source' ) { - queryArgs.source = filter.value; + if ( filter.value === FORM_PREVIEW_SOURCE_VALUE ) { + queryArgs.is_test = true; + } else { + queryArgs.source = filter.value; + } } if ( filter.field === 'date' ) { const [ year, month ] = filter.value.split( '/' ).map( Number ); @@ -432,9 +441,16 @@ function StageInner() { /> { styleUnreadValue( - - { displayName } - + + + { displayName } + + { item.is_test && ( + + { __( 'Test', 'jetpack-forms' ) } + + ) } + { showEmail && ( { item.author_email } @@ -479,6 +495,18 @@ function StageInner() { id: 'source', label: __( 'Source', 'jetpack-forms' ), render: ( { item } ) => { + // Test responses point at the regenerated preview URL instead of + // the hosting page, and always surface as "Form preview". + if ( item.is_test ) { + const previewLabel = __( 'Form preview', 'jetpack-forms' ); + if ( item.preview_url ) { + return styleUnreadValue( + { previewLabel }, + item.is_unread + ); + } + return styleUnreadValue( previewLabel, item.is_unread ); + } const source = item.entry_title || getUrlPath( item.entry_permalink ) || @@ -491,15 +519,19 @@ function StageInner() { } return styleUnreadValue( source, item.is_unread ); }, - elements: ( ( filterOptions as unknown as FeedbackFilters )?.source || [] ).map( - source => ( { + elements: [ + { + value: FORM_PREVIEW_SOURCE_VALUE, + label: __( 'Form preview', 'jetpack-forms' ), + }, + ...( ( filterOptions as unknown as FeedbackFilters )?.source || [] ).map( source => ( { value: source.id.toString(), label: decodeEntities( source.title ) || getUrlPath( source.url ) || __( '(no title)', 'jetpack-forms' ), - } ) - ), + } ) ), + ], filterBy: isSingleFormView ? false : { operators: [ 'is' ] as Operator[] }, enableSorting: false, }, diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index e9a687747dcd..0ab1ef18c7fa 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -373,6 +373,12 @@ public function register_routes() { 'sanitize_callback' => 'rest_sanitize_boolean', 'validate_callback' => 'rest_validate_request_arg', ), + 'is_test' => array( + 'description' => 'Limit results to test responses or exclude them.', + 'type' => 'boolean', + 'sanitize_callback' => 'rest_sanitize_boolean', + 'validate_callback' => 'rest_validate_request_arg', + ), 'source' => array( 'description' => 'Limit results to feedback submitted from a specific source post ID.', 'type' => 'integer', @@ -477,6 +483,7 @@ public function get_status_counts( $request ) { $before = $request->get_param( 'before' ); $after = $request->get_param( 'after' ); $is_unread = $request->get_param( 'is_unread' ); + $is_test = $request->get_param( 'is_test' ); $join_clause = ''; $where_conditions = array( $wpdb->prepare( "{$wpdb->posts}.post_type = %s", 'feedback' ) ); @@ -509,6 +516,12 @@ public function get_status_counts( $request ) { $where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.comment_status = %s", $comment_status ); } + if ( null !== $is_test ) { + $is_test_meta_key = esc_sql( Feedback::IS_TEST_META_KEY ); + $join_clause .= " LEFT JOIN {$wpdb->postmeta} AS is_test_meta ON ({$wpdb->posts}.ID = is_test_meta.post_id AND is_test_meta.meta_key = '{$is_test_meta_key}')"; + $where_conditions[] = $is_test ? "is_test_meta.meta_value = '1'" : 'is_test_meta.meta_id IS NULL'; + } + $where_clause = implode( ' AND ', $where_conditions ); // Execute single query with CASE statements for all status counts. @@ -807,6 +820,26 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['is_test'] = array( + 'description' => __( 'Whether the form response was submitted from a form preview (test response).', 'jetpack-forms' ), + 'type' => 'boolean', + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'sanitize_callback' => 'rest_sanitize_boolean', + ), + 'readonly' => true, + ); + + $schema['properties']['preview_url'] = array( + 'description' => __( 'URL to the form preview that produced this response, when the response is a test submission.', 'jetpack-forms' ), + 'type' => array( 'string', 'null' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'sanitize_callback' => 'esc_url_raw', + ), + 'readonly' => true, + ); + $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); @@ -988,6 +1021,21 @@ public function prepare_item_for_response( $item, $request ) { $data['is_unread'] = $feedback_response->is_unread(); } + if ( rest_is_field_included( 'is_test', $fields ) ) { + $data['is_test'] = $feedback_response->is_test(); + } + + if ( rest_is_field_included( 'preview_url', $fields ) ) { + $preview_url = null; + if ( $feedback_response->is_test() ) { + $form_id = $feedback_response->get_form_id(); + if ( $form_id ) { + $preview_url = Form_Preview::generate_preview_url( (int) $form_id ); + } + } + $data['preview_url'] = $preview_url; + } + $response->set_data( $data ); return rest_ensure_response( $response ); @@ -1102,6 +1150,25 @@ protected function prepare_items_query( $args = array(), $request = null ) { add_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10, 2 ); } + // Filter by test/non-test responses via the _feedback_is_test meta. + $is_test = $request->get_param( 'is_test' ); + if ( null !== $is_test ) { + $meta_query = isset( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : array(); + if ( $is_test ) { + $meta_query[] = array( + 'key' => Feedback::IS_TEST_META_KEY, + 'value' => '1', + 'compare' => '=', + ); + } else { + $meta_query[] = array( + 'key' => Feedback::IS_TEST_META_KEY, + 'compare' => 'NOT EXISTS', + ); + } + $args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + } + return $args; } @@ -1171,6 +1238,12 @@ public function get_collection_params() { 'sanitize_callback' => 'rest_sanitize_boolean', 'validate_callback' => 'rest_validate_request_arg', ); + $query_params['is_test'] = array( + 'description' => __( 'Limit result set to test responses (from form preview) or exclude them.', 'jetpack-forms' ), + 'type' => 'boolean', + 'sanitize_callback' => 'rest_sanitize_boolean', + 'validate_callback' => 'rest_validate_request_arg', + ); $query_params['invalid_ids'] = array( 'description' => __( 'List of item IDs to include in results regardless of filters.', 'jetpack-forms' ), 'type' => 'array', diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php index 1c07533a2003..539230905b39 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php @@ -1607,11 +1607,6 @@ public static function recalculate_unread_count() { * Conditionally attached to `template_redirect` */ public function process_form_submission() { - // Block submissions in preview mode. - if ( Form_Preview::is_preview_mode() ) { - return; - } - // Add a filter to replace tokens in the subject field with sanitized field values. add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 ); @@ -1721,6 +1716,14 @@ public function process_form_submission() { if ( Jetpack_Forms::is_webhooks_enabled() && ! empty( $form->attributes['webhooks'] ) ) { Form_Webhooks::init(); } + + // The decoded JWT carries a serialized Feedback_Source; when the + // form was rendered in preview mode that source has is_test=true. + // Flag the submission accordingly so the response is stored as a + // test response. JWTs issued before this feature shipped simply + // omit the flag and behave as regular submissions. + $form->set_is_preview_submission( $form->get_source()->is_test() ); + // Process the form return $form->process_submission(); } @@ -2906,11 +2909,14 @@ public function personal_data_search_filter( $search ) { /** * Returns an array of feedback data for export. * - * @param array $feedback_ids Array of feedback IDs to fetch the data for. + * @param array $feedback_ids Array of feedback IDs to fetch the data for. + * @param bool $include_test_responses Whether to include feedback that was submitted + * from form preview. Defaults to false, meaning + * preview/test responses are excluded from the export. * * @return array */ - public function get_export_feedback_data( $feedback_ids ) { + public function get_export_feedback_data( $feedback_ids, $include_test_responses = false ) { $feedback_data = array(); $all_field_names = array(); @@ -2921,6 +2927,11 @@ public function get_export_feedback_data( $feedback_ids ) { continue; // Skip if the feedback is not an instance of Feedback. } + // Skip test responses from form preview unless explicitly requested. + if ( ! $include_test_responses && $response->is_test() ) { + continue; + } + // Get fields with automatic duplicate handling (label-value shape includes counts) $compiled_fields = $response->get_compiled_fields( 'csv', 'label-value' ); @@ -3078,7 +3089,8 @@ public function get_feedback_entries_from_post() { } } - if ( ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] ) ) { + $has_explicit_selection = ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] ); + if ( $has_explicit_selection ) { $args['include'] = array_filter( array_map( function ( $selected ) { @@ -3091,7 +3103,11 @@ function ( $selected ) { $feedbacks = get_posts( $args ); - return $this->get_export_feedback_data( $feedbacks ); + // Test responses from form preview are excluded from bulk exports by + // default. When the user has explicitly picked specific rows (via the + // dashboard selection UI), we trust their selection and include any + // test responses that landed in it. + return $this->get_export_feedback_data( $feedbacks, $has_explicit_selection ); } /** diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index a265941cafbe..91034131ddc5 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -137,6 +137,17 @@ class Contact_Form extends Contact_Form_Shortcode { */ public $has_verified_jwt = false; + /** + * Whether the current submission originated from an authenticated form preview. + * + * When true, the resulting feedback is marked as a test submission — Akismet + * is skipped, the notification email is annotated, and the response is + * excluded from the default CSV export. + * + * @var bool + */ + private $is_preview_submission = false; + /** * The source of the feedback entry. * @@ -619,6 +630,25 @@ public function set_source( $source ) { $this->source = $source; } + /** + * Flag whether the current submission originated from an authenticated form preview. + * + * @param bool $is_preview_submission Whether the submission came from form preview. + * @return void + */ + public function set_is_preview_submission( $is_preview_submission ) { + $this->is_preview_submission = (bool) $is_preview_submission; + } + + /** + * Whether the current submission is a test submission coming from form preview. + * + * @return bool + */ + public function is_preview_submission() { + return $this->is_preview_submission; + } + /** * Get the context for the contact form based on the attributes and post. * @@ -1249,7 +1279,6 @@ public static function parse( $attributes, $content, $context = array() ) { 'invalid_form_empty' => __( 'The form you are trying to submit is empty.', 'jetpack-forms' ), 'invalid_form' => __( 'Please fill out the form correctly.', 'jetpack-forms' ), 'network_error' => __( 'Connection issue while submitting the form. Check that you are connected to the Internet and try again.', 'jetpack-forms' ), - 'preview_mode' => __( 'Form submissions are disabled in preview mode.', 'jetpack-forms' ), ), 'admin_ajax_url' => admin_url( 'admin-ajax.php' ), ); @@ -2602,6 +2631,16 @@ public function process_submission() { $response = Feedback::from_submission( $_POST, $this ); // phpcs:Ignore WordPress.Security.NonceVerification.Missing $response->set_source( $this->get_source() ); + + // If the submission came from an authenticated form preview, flag the + // feedback as a test submission. The rest of the pipeline reads the + // flag from the feedback (which also travels into the serialized + // post_content via Feedback_Source). + if ( $this->is_preview_submission ) { + $response->mark_as_test(); + } + $is_test_submission = $response->is_test(); + $plugin = Contact_Form_Plugin::init(); $id = $this->get_attribute( 'id' ); @@ -2692,9 +2731,15 @@ public function process_submission() { $spam = ''; $akismet_values = $plugin->prepare_for_akismet( $akismet_vars ); - // Is it spam? - /** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */ - $is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values ); + // Is it spam? Test submissions (from form preview) skip Akismet entirely — + // the form owner is explicitly running a test and we don't want Akismet + // to learn from synthetic data or bounce the submission. + if ( $is_test_submission ) { + $is_spam = false; + } else { + /** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */ + $is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values ); + } if ( is_wp_error( $is_spam ) ) { // WP_Error to abort return $is_spam; // abort } elseif ( $is_spam === true ) { // TRUE to flag a spam @@ -2790,6 +2835,22 @@ public function process_submission() { $entry_values = $response->get_entry_values(); + // Prefix the subject with [TEST] for test submissions so the form owner + // can immediately tell this email came from a preview-mode submission. + if ( $is_test_submission ) { + /** + * Filter the subject prefix applied to test (preview) feedback emails. + * + * @module contact-form + * + * @since $$next-version$$ + * + * @param string $prefix Default subject prefix for test submissions. + */ + $test_prefix = apply_filters( 'jetpack_forms_test_subject_prefix', '[TEST] ' ); + $contact_form_subject = $test_prefix . $contact_form_subject; + } + /** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */ $subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values ); @@ -2901,6 +2962,7 @@ public function process_submission() { 'comment_author_email' => $comment_author_email, 'comment_author_ip' => $comment_author_ip, 'is_spam' => $is_spam, + 'is_test' => $is_test_submission, 'feedback_status' => $feedback_status, ); $email = Feedback_Email_Renderer::build_email_content( $post_id, $this, $response, $context_data ); @@ -2937,6 +2999,25 @@ public function process_submission() { $send_email = ( $this->get_attribute( 'emailNotifications' ) !== 'no' ); } + // Test submissions always send the notification email (so the form + // owner can verify their email flow end-to-end) regardless of the + // emailNotifications attribute. Site admins who want to opt out can + // return false from the filter below. + if ( $is_test_submission ) { + /** + * Filter whether test (preview) submissions should trigger the notification email. + * + * @module contact-form + * + * @since $$next-version$$ + * + * @param bool $send Whether to send the test submission email. Default true. + * @param int $post_id The feedback post ID. + * @param Feedback $response The feedback response object. + */ + $send_email = apply_filters( 'jetpack_forms_send_test_feedback_email', true, $post_id, $response ); + } + /** * Filter to determine if spam should still be emailed. * diff --git a/projects/packages/forms/src/contact-form/class-feedback-email-renderer.php b/projects/packages/forms/src/contact-form/class-feedback-email-renderer.php index 379126a27251..227deefffdd0 100644 --- a/projects/packages/forms/src/contact-form/class-feedback-email-renderer.php +++ b/projects/packages/forms/src/contact-form/class-feedback-email-renderer.php @@ -103,6 +103,7 @@ public static function build_email_content( $post_id, $form, $response, $context $comment_author_email = $context_data['comment_author_email']; $comment_author_ip = $context_data['comment_author_ip']; $is_spam = $context_data['is_spam']; + $is_test = ! empty( $context_data['is_test'] ); $feedback_status = $context_data['feedback_status']; /** @@ -168,13 +169,18 @@ public static function build_email_content( $post_id, $form, $response, $context $footer_mark_as_spam_url = ''; if ( $feedback_status !== 'jp-temp-feedback' ) { - $dashboard_url = Forms_Dashboard::get_forms_admin_url( $status, $post_id ); - $mark_as_spam_url = self::add_mark_as_spam_to_url( $dashboard_url ); - $footer_mark_as_spam_url = sprintf( - '%2$s', - esc_url( $mark_as_spam_url ), - __( 'Mark as spam', 'jetpack-forms' ) - ); + $dashboard_url = Forms_Dashboard::get_forms_admin_url( $status, $post_id ); + // Test responses don't get a Mark-as-spam link in the email — marking + // a test entry as spam from email is confusing and the form owner can + // always do it from the dashboard if they want. + if ( ! $is_test ) { + $mark_as_spam_url = self::add_mark_as_spam_to_url( $dashboard_url ); + $footer_mark_as_spam_url = sprintf( + '%2$s', + esc_url( $mark_as_spam_url ), + __( 'Mark as spam', 'jetpack-forms' ) + ); + } } $footer = implode( @@ -209,23 +215,39 @@ public static function build_email_content( $post_id, $form, $response, $context // Use fully table-based layout for maximum email client compatibility - no display:inline-block. $actions = ''; if ( $dashboard_url ) { - $actions = sprintf( - ' - - - - - ', - esc_url( $mark_as_spam_url ), - __( 'Mark as spam', 'jetpack-forms' ), - esc_url( $dashboard_url ), - __( 'View in dashboard', 'jetpack-forms' ), - self::LINK_COLOR - ); + if ( $is_test ) { + // For test submissions, only show the "View in dashboard" button + // centered — we deliberately drop the Mark-as-spam shortcut. + $actions = sprintf( + ' + + + + ', + esc_url( $dashboard_url ), + __( 'View in dashboard', 'jetpack-forms' ) + ); + } else { + $actions = sprintf( + ' + + + + + ', + esc_url( $mark_as_spam_url ), + __( 'Mark as spam', 'jetpack-forms' ), + esc_url( $dashboard_url ), + __( 'View in dashboard', 'jetpack-forms' ), + self::LINK_COLOR + ); + } } // Build respondent info for the new email template. @@ -241,11 +263,21 @@ public static function build_email_content( $post_id, $form, $response, $context $form_title = Contact_Form::get_post_property( $form->current_post, 'post_title' ); } + // Test responses don't have a real source page; surface them as + // "Form preview" in the metadata table to match the dashboard. + if ( $is_test ) { + $source_label = __( 'Form preview', 'jetpack-forms' ); + $source_url = ''; + } else { + $source_label = $form_title; + $source_url = $url; + } + // Build metadata for the new email template. $metadata = array( 'date' => $time, - 'source' => $form_title, - 'source_url' => $url, + 'source' => $source_label, + 'source_url' => $source_url, 'device' => $response->get_browser(), 'ip' => $comment_author_ip, 'ip_flag' => $response->get_country_flag(), @@ -264,8 +296,14 @@ public static function build_email_content( $post_id, $form, $response, $context */ $message = apply_filters( 'contact_form_message', implode( '', $message ), $message ); + // Render a prominent TEST SUBMISSION banner when this came from a form + // preview, so the form owner can immediately tell that this response is + // a synthetic test. It is injected at the very top of the email so the + // rest of the body still looks like a normal submission email. + $banner = $is_test ? self::build_test_submission_banner() : ''; + // This is called after `contact_form_message`, in order to preserve back-compat. - $message = self::wrap_message_in_html_tags( $title, $message, $footer, $actions, $respondent_info, $metadata ); + $message = self::wrap_message_in_html_tags( $title, $message, $footer, $actions, $respondent_info, $metadata, $banner ); return array( 'title' => $title, @@ -273,6 +311,26 @@ public static function build_email_content( $post_id, $form, $response, $context ); } + /** + * Build the HTML banner inserted at the top of a test-submission email body. + * + * Uses inline styles and a nested table layout for email-client compatibility. + * Mirrors the @wordpress/ui Notice component (warning intent): warm amber fill, + * 1px amber border with 8px radius, decorative info icon, 13px/20px body copy. + * + * @return string + */ + private static function build_test_submission_banner() { + return sprintf( + ' + + + + ', + esc_html__( 'Test response via form preview.', 'jetpack-forms' ) + ); + } + /** * Adds the mark_as_spam parameter to a dashboard URL. * @@ -529,10 +587,11 @@ public static function add_plain_text_alternative( $phpmailer ) { * @param string $actions - HTML for actions displayed in the email. * @param array $respondent_info - Optional. Respondent information array with 'name', 'email', 'avatar'. * @param array $metadata - Optional. Metadata array with 'date', 'source', 'source_url', 'device', 'ip', 'ip_flag', 'logged_in_user' (with display_name, username, id). + * @param string $banner - Optional. HTML banner inserted at the very top of the email body (above the title). * * @return string */ - public static function wrap_message_in_html_tags( $title, $body, $footer, $actions = '', $respondent_info = array(), $metadata = array() ) { + public static function wrap_message_in_html_tags( $title, $body, $footer, $actions = '', $respondent_info = array(), $metadata = array(), $banner = '' ) { // Don't do anything if the message was already wrapped in HTML tags // That could have be done by a plugin via filters. if ( str_contains( $body, ' for Outlook.com compatibility (it strips styles diff --git a/projects/packages/forms/src/contact-form/class-feedback-source.php b/projects/packages/forms/src/contact-form/class-feedback-source.php index 1cadc4a7097b..89693d5692b9 100644 --- a/projects/packages/forms/src/contact-form/class-feedback-source.php +++ b/projects/packages/forms/src/contact-form/class-feedback-source.php @@ -58,6 +58,17 @@ class Feedback_Source { */ private $request_url = ''; + /** + * Whether this feedback was created from a form preview submission. + * + * Test feedback is stored normally in the inbox but is distinguished in the + * notification email, excluded from the default CSV export, and skips the + * spam/Akismet pipeline. + * + * @var bool + */ + private $is_test = false; + /** * Constructor for Feedback_Source. * @@ -66,8 +77,9 @@ class Feedback_Source { * @param int $page_number The page number of the feedback entry, default is 1. * @param string $source_type The source type of the feedback entry, default is 'single'. * @param string $request_url The request URL of the feedback entry. + * @param bool $is_test Whether the feedback was submitted from a form preview. */ - public function __construct( $id = 0, $title = '', $page_number = 1, $source_type = 'single', $request_url = '' ) { + public function __construct( $id = 0, $title = '', $page_number = 1, $source_type = 'single', $request_url = '', $is_test = false ) { if ( is_numeric( $id ) ) { $this->id = $id > 0 ? $id : 0; @@ -85,6 +97,7 @@ public function __construct( $id = 0, $title = '', $page_number = 1, $source_typ $this->permalink = empty( $request_url ) ? home_url() : $request_url; $this->source_type = $source_type; // possible source types: single, widget, block_template, block_template_part $this->request_url = $request_url; + $this->is_test = (bool) $is_test; if ( is_numeric( $id ) && ! empty( $id ) ) { $entry_post = get_post( (int) $id ); @@ -164,20 +177,27 @@ private static function get_source_title() { public static function get_current( $attributes ) { global $wp, $page; $current_url = home_url( add_query_arg( array(), $wp->request ) ); + + // When a form is rendered inside the server-side preview + // (Form_Preview::maybe_render_preview), flag the source as a test + // submission. The flag travels with the signed JWT and is read back + // at submission time to branch the response into the test pipeline. + $is_test = Form_Preview::is_preview_mode(); + if ( isset( $attributes['widget'] ) && ! empty( $attributes['widget'] ) ) { - return new self( $attributes['widget'], self::get_source_title(), 1, 'widget', $current_url ); + return new self( $attributes['widget'], self::get_source_title(), 1, 'widget', $current_url, $is_test ); } if ( isset( $attributes['block_template'] ) && ! empty( $attributes['block_template'] ) ) { global $_wp_current_template_id; - return new self( $_wp_current_template_id, self::get_source_title(), $page, 'block_template', $current_url ); + return new self( $_wp_current_template_id, self::get_source_title(), $page, 'block_template', $current_url, $is_test ); } if ( isset( $attributes['block_template_part'] ) && ! empty( $attributes['block_template_part'] ) ) { - return new self( $attributes['block_template_part'], self::get_source_title(), $page, 'block_template_part', $current_url ); + return new self( $attributes['block_template_part'], self::get_source_title(), $page, 'block_template_part', $current_url, $is_test ); } - return new Feedback_Source( \get_the_ID(), \get_the_title(), $page, 'single', $current_url ); + return new Feedback_Source( \get_the_ID(), \get_the_title(), $page, 'single', $current_url, $is_test ); } /** @@ -192,8 +212,9 @@ public static function from_serialized( $data ) { $page_number = $data['entry_page'] ?? 1; $source_type = $data['source_type'] ?? 'single'; $request_url = $data['request_url'] ?? ''; + $is_test = ! empty( $data['is_test'] ); - return new self( $id, $title, $page_number, $source_type, $request_url ); + return new self( $id, $title, $page_number, $source_type, $request_url, $is_test ); } /** @@ -277,18 +298,43 @@ public function get_id() { return $this->id; } + /** + * Whether this feedback was submitted from a form preview (test submission). + * + * @return bool + */ + public function is_test() { + return $this->is_test; + } + + /** + * Flag this feedback as a test submission coming from form preview. + * + * @param bool $is_test Whether the feedback is a test submission. + * @return void + */ + public function set_is_test( $is_test ) { + $this->is_test = (bool) $is_test; + } + /** * Get the page number of the entry title. * * @return array */ public function serialize() { - return array( + $data = array( 'entry_title' => $this->title, 'entry_page' => $this->page_number, 'source_id' => $this->id, 'source_type' => $this->source_type, 'request_url' => $this->request_url, ); + + if ( $this->is_test ) { + $data['is_test'] = true; + } + + return $data; } } diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index f6ab0f2d5a27..38034ae4a4aa 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -42,6 +42,16 @@ class Feedback { */ public const SOURCE_META_KEY = '_feedback_source_post_id'; + /** + * Post meta key flagging a feedback entry as a test submission (from a + * form preview). Stored as `1` when `Feedback_Source::is_test()` is true + * so collections can filter test responses at the database level without + * parsing the serialized source. + * + * @var string + */ + public const IS_TEST_META_KEY = '_feedback_is_test'; + /** * Cache key for the source post IDs list. * @@ -388,7 +398,8 @@ private function load_from_post( WP_Post $feedback_post ) { $parsed_content['entry_title'] ?? '', $parsed_content['entry_page'] ?? 1, $parsed_content['source_type'] ?? 'single', - $parsed_content['request_url'] ?? '' + $parsed_content['request_url'] ?? '', + ! empty( $parsed_content['is_test'] ) ); $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' ); @@ -1456,6 +1467,25 @@ public function get_edit_form_url() { public function get_entry_short_permalink() { return $this->source->get_relative_permalink(); } + + /** + * Whether this feedback was submitted from a form preview (test submission). + * + * @return bool + */ + public function is_test() { + return $this->source->is_test(); + } + + /** + * Flag this feedback as a test submission from form preview. + * + * @return void + */ + public function mark_as_test() { + $this->source->set_is_test( true ); + } + /** * Save the feedback entry to the database. * @@ -1483,6 +1513,12 @@ public function save() { wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP ); } + // Flag test submissions with a post meta so the REST collection can + // filter them via meta_query without unpacking the serialized source. + if ( is_numeric( $post_id ) && (int) $post_id > 0 && $this->source->is_test() ) { + add_post_meta( $post_id, self::IS_TEST_META_KEY, 1, true ); + } + // If this feedback does not have a jetpack_form parent, // it's a classic form — mark the state accordingly. if ( empty( $this->form_id ) ) { diff --git a/projects/packages/forms/src/contact-form/class-form-preview.php b/projects/packages/forms/src/contact-form/class-form-preview.php index 84c8c24e1815..f27ee1787c6f 100644 --- a/projects/packages/forms/src/contact-form/class-form-preview.php +++ b/projects/packages/forms/src/contact-form/class-form-preview.php @@ -295,6 +295,12 @@ function ( $title, $id = null ) use ( $form, $form_id ) { /** * Render the form preview content. * + * The rendered markup flows through the normal block pipeline, which + * embeds a signed JWT carrying the form's serialized source. Because + * Feedback_Source::get_current() reads Form_Preview::is_preview_mode() + * at render time, the JWT issued here travels to submission with + * `is_test: true` baked into its source — no hidden nonce fields needed. + * * @param WP_Post|null $form The form post. * @return string The rendered content. */ @@ -307,12 +313,11 @@ private static function render_form_preview_content( ?WP_Post $form ) { // Add preview banner. $output .= '
'; - $output .= esc_html__( 'This is a preview. Form submissions are disabled.', 'jetpack-forms' ); + $output .= esc_html__( 'This is a preview. Submissions are saved as test responses.', 'jetpack-forms' ); $output .= '
'; // Parse and render the form blocks. $blocks = parse_blocks( $form->post_content ); - foreach ( $blocks as $block ) { $output .= render_block( $block ); } diff --git a/projects/packages/forms/src/contact-form/templates/email-response.php b/projects/packages/forms/src/contact-form/templates/email-response.php index 0c582d11caea..60d746682f50 100644 --- a/projects/packages/forms/src/contact-form/templates/email-response.php +++ b/projects/packages/forms/src/contact-form/templates/email-response.php @@ -14,6 +14,7 @@ * %9$s is powered by email logo. * %10$s is the respondent info section (avatar, name, email). * %11$s is the metadata section (Date, Source, Device, IP). + * %12$s is an optional banner rendered at the very top of the email (e.g. test-submission notice). * * @package automattic/jetpack */ @@ -60,6 +61,10 @@
%1$s + + + %12$s + diff --git a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts index 3cdbb8e9fcb5..af4a299b0958 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts @@ -260,6 +260,9 @@ export default function useInboxData( options: UseInboxDataOptions = {} ): UseIn if ( currentQuery?.is_unread !== undefined ) { params.is_unread = currentQuery.is_unread; } + if ( currentQuery?.is_test !== undefined ) { + params.is_test = currentQuery.is_test; + } return params; }, [ currentQuery ] ); diff --git a/projects/packages/forms/src/dashboard/inbox/stage/actions.tsx b/projects/packages/forms/src/dashboard/inbox/stage/actions.tsx index 1f716119c463..9b7b166f5e3f 100644 --- a/projects/packages/forms/src/dashboard/inbox/stage/actions.tsx +++ b/projects/packages/forms/src/dashboard/inbox/stage/actions.tsx @@ -48,6 +48,9 @@ const getCountQueryParams = ( currentQuery: QueryParams ): QueryParams => { if ( currentQuery?.is_unread !== undefined ) { queryParams.is_unread = currentQuery.is_unread; } + if ( currentQuery?.is_test !== undefined ) { + queryParams.is_test = currentQuery.is_test; + } return queryParams; }; diff --git a/projects/packages/forms/src/dashboard/inbox/stage/index.js b/projects/packages/forms/src/dashboard/inbox/stage/index.js index 80c84a790748..8919cb319f09 100644 --- a/projects/packages/forms/src/dashboard/inbox/stage/index.js +++ b/projects/packages/forms/src/dashboard/inbox/stage/index.js @@ -51,6 +51,10 @@ import { useView, defaultLayouts } from './views.js'; const EMPTY_ARRAY = []; +// Sentinel value used in the Source filter to represent form-preview (test) responses. +// Source IDs are numeric post IDs, so this non-numeric value is safe from collision. +const FORM_PREVIEW_SOURCE_VALUE = 'form_preview'; + const updateSidebarWidth = () => { const wrapper = document.querySelector( '.dataviews-wrapper' ); @@ -173,7 +177,11 @@ export default function InboxView( { parentId, pageTitle, pageSubtitle } = {} ) return accumulator; } if ( field === 'source' ) { - accumulator.source = value; + if ( value === FORM_PREVIEW_SOURCE_VALUE ) { + accumulator.is_test = true; + } else { + accumulator.source = value; + } } if ( field === 'date' ) { const [ year, month ] = value.split( '/' ).map( Number ); @@ -350,6 +358,17 @@ export default function InboxView( { parentId, pageTitle, pageSubtitle } = {} ) id: 'source', label: __( 'Source', 'jetpack-forms' ), render: ( { item } ) => { + if ( item.is_test ) { + const previewLabel = __( 'Form preview', 'jetpack-forms' ); + if ( item.preview_url ) { + return ( + + { wrapperUnread( item.is_unread, previewLabel ) } + + ); + } + return wrapperUnread( item.is_unread, previewLabel ); + } if ( ! item.entry_permalink ) { return wrapperUnread( item.is_unread, decodeEntities( item.entry_title ) ); } @@ -362,10 +381,17 @@ export default function InboxView( { parentId, pageTitle, pageSubtitle } = {} ) ); }, - elements: ( filterOptions?.source || [] ).map( source => ( { - value: source.id, - label: decodeEntities( source.title ) || getPath( { entry_permalink: source.url } ), - } ) ), + elements: [ + { + value: FORM_PREVIEW_SOURCE_VALUE, + label: __( 'Form preview', 'jetpack-forms' ), + }, + ...( filterOptions?.source || [] ).map( source => ( { + value: source.id, + label: + decodeEntities( source.title ) || getPath( { entry_permalink: source.url } ), + } ) ), + ], filterBy: { operators: [ 'is' ] }, enableSorting: false, }, diff --git a/projects/packages/forms/src/dashboard/inbox/stage/types.tsx b/projects/packages/forms/src/dashboard/inbox/stage/types.tsx index efdaa526d4c3..d58b284728aa 100644 --- a/projects/packages/forms/src/dashboard/inbox/stage/types.tsx +++ b/projects/packages/forms/src/dashboard/inbox/stage/types.tsx @@ -12,6 +12,7 @@ export type QueryParams = { before?: string; after?: string; is_unread?: boolean; + is_test?: boolean; per_page?: number; page?: number; status?: string; diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js index 5ccbf699f9ca..8a5aa3b359f7 100644 --- a/projects/packages/forms/src/modules/form/view.js +++ b/projects/packages/forms/src/modules/form/view.js @@ -632,22 +632,10 @@ const { state, actions } = store( NAMESPACE, { onFormSubmit: withSyncEvent( function* ( event ) { const context = getContext(); - // Check if we're in preview mode and block submission. - if ( window.jetpackFormsPreviewMode ) { - event.preventDefault(); - event.stopPropagation(); - context.submissionError = config.error_types?.preview_mode; - - if ( errorTimeout ) { - clearTimeout( errorTimeout ); - } - - errorTimeout = setTimeout( () => { - context.submissionError = null; - }, 5000 ); - - return; - } + // Form preview used to block submissions entirely. Now submissions + // from preview are allowed and marked as test responses on the + // server. Leave `window.jetpackFormsPreviewMode` untouched so other + // code can still detect preview context if needed. if ( ! state.isFormValid ) { context.showErrors = true; diff --git a/projects/packages/forms/src/types/index.ts b/projects/packages/forms/src/types/index.ts index b53cebafccd4..aa53f60eed15 100644 --- a/projects/packages/forms/src/types/index.ts +++ b/projects/packages/forms/src/types/index.ts @@ -150,6 +150,10 @@ export interface FormResponse { has_file: boolean; /** Whether the response is unread. */ is_unread: boolean; + /** Whether the response is a test submission from form preview. */ + is_test?: boolean; + /** URL to the form preview that produced this response, when the response is a test submission. */ + preview_url?: string | null; /** The fields of the response (can be new collection format or legacy format). */ fields: ResponseFields; /** The URL to edit the form that the response was submitted to. */ diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php index fa3cfb446cf5..2c24b818401c 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php @@ -176,6 +176,9 @@ public function test_item_schema() { $this->assertArrayHasKey( 'subject', $schema_properties ); $this->assertArrayHasKey( 'fields', $schema_properties ); $this->assertArrayHasKey( 'is_unread', $schema_properties ); + $this->assertArrayHasKey( 'is_test', $schema_properties ); + $this->assertEquals( 'boolean', $schema_properties['is_test']['type'] ); + $this->assertArrayHasKey( 'preview_url', $schema_properties ); // Verify logged_in_user schema structure $logged_in_user_schema = $schema_properties['logged_in_user']; @@ -196,6 +199,118 @@ public function test_item_schema() { $this->assertArrayNotHasKey( 'excerpt', $schema_properties ); } + /** + * Helper: insert a v3-format feedback post, optionally flagged as a test submission. + * + * @param bool $is_test Whether to mark the feedback as a test submission. + * @return int The new feedback post ID. + */ + private function insert_v3_feedback_post( $is_test = false ) { + $content = array( + 'subject' => 'Subject', + 'ip' => '127.0.0.1', + 'entry_title' => 'Source Post', + 'entry_page' => 1, + 'source_id' => 0, + 'source_type' => 'single', + 'request_url' => '', + 'fields' => array( + array( + 'id' => '1_Name', + 'label' => 'Name', + 'type' => 'text', + 'value' => $is_test ? 'Preview Tester' : 'Real User', + ), + ), + ); + + if ( $is_test ) { + $content['is_test'] = true; + } + + Feedback::clear_cache(); + + return wp_insert_post( + array( + 'post_type' => 'feedback', + 'post_status' => 'publish', + 'post_title' => 'Response ' . ( $is_test ? 'test' : 'real' ) . ' ' . microtime(), + 'post_content' => wp_json_encode( $content, JSON_UNESCAPED_SLASHES ), + 'post_mime_type' => 'v3', + ) + ); + } + + /** + * A real feedback row exposes is_test = false and preview_url = null. + */ + public function test_get_item_exposes_is_test_false_for_real_feedback() { + $post_id = $this->insert_v3_feedback_post( false ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/feedback/' . $post_id ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'is_test', $data ); + $this->assertFalse( $data['is_test'] ); + $this->assertArrayHasKey( 'preview_url', $data ); + $this->assertNull( $data['preview_url'] ); + + wp_delete_post( $post_id, true ); + } + + /** + * A feedback row flagged as test exposes is_test = true and a preview_url + * when a parent form is available and the current user can preview it. + */ + public function test_get_item_exposes_is_test_true_for_preview_feedback() { + // Register the jetpack_form CPT so the Feedback loader picks up the + // post_parent as the form_id and preview URL generation can run. + if ( ! post_type_exists( Contact_Form::POST_TYPE ) ) { + register_post_type( + Contact_Form::POST_TYPE, + array( + 'public' => false, + 'show_ui' => true, + 'map_meta_cap' => true, + ) + ); + } + $form_id = wp_insert_post( + array( + 'post_type' => Contact_Form::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => 'Preview Test Form', + 'post_content' => '', + 'post_author' => self::$user_id, + ) + ); + + $post_id = $this->insert_v3_feedback_post( true ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_parent' => $form_id, + ) + ); + Feedback::clear_cache(); + + $request = new WP_REST_Request( 'GET', '/wp/v2/feedback/' . $post_id ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'is_test', $data ); + $this->assertTrue( $data['is_test'] ); + $this->assertArrayHasKey( 'preview_url', $data ); + $this->assertIsString( $data['preview_url'] ); + $this->assertStringContainsString( 'jetpack_form_preview=' . $form_id, $data['preview_url'] ); + + wp_delete_post( $post_id, true ); + wp_delete_post( $form_id, true ); + } + /** * Test GET feedback/integrations with version 1 format */ diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php index e780adbb9361..476b88ae9328 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php @@ -916,6 +916,95 @@ public function test_get_export_feedback_data_structure() { Utility::destroy_post_context( $current_post ); } + /** + * Helper: insert a v3-format feedback post, optionally flagged as a test submission. + * + * @param bool $is_test Whether to mark the feedback as a test submission. + * @return int The new feedback post ID. + */ + private function insert_v3_feedback_post( $is_test = false ) { + $content = array( + 'subject' => 'Test Subject', + 'ip' => '127.0.0.1', + 'entry_title' => 'Source Post', + 'entry_page' => 1, + 'source_id' => 0, + 'source_type' => 'single', + 'request_url' => '', + 'fields' => array( + array( + 'id' => '1_Name', + 'label' => 'Name', + 'type' => 'text', + 'value' => $is_test ? 'Preview Tester' : 'Real User', + ), + ), + ); + + if ( $is_test ) { + $content['is_test'] = true; + } + + // Clear the Feedback static cache so repeat calls in one test see fresh data. + Feedback::clear_cache(); + + return wp_insert_post( + array( + 'post_type' => 'feedback', + 'post_status' => 'publish', + 'post_title' => 'Preview ' . ( $is_test ? 'test' : 'real' ) . ' ' . microtime(), + 'post_content' => wp_json_encode( $content, JSON_UNESCAPED_SLASHES ), + 'post_mime_type' => 'v3', + ) + ); + } + + /** + * By default, the export excludes feedback flagged as test submissions. + */ + public function test_export_excludes_test_feedback_by_default() { + $plugin = Contact_Form_Plugin::init(); + $real_id = $this->insert_v3_feedback_post( false ); + $test_id = $this->insert_v3_feedback_post( true ); + + $result = $plugin->get_export_feedback_data( array( $real_id, $test_id ) ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( ' ID', $result ); + $this->assertEquals( + array( $real_id ), + $result[' ID'], + 'The default export should only return the non-test feedback row.' + ); + + wp_delete_post( $real_id, true ); + wp_delete_post( $test_id, true ); + } + + /** + * Callers that pass an explicit selection (e.g. the dashboard's selected + * row IDs) include the test responses in that selection — the user + * deliberately picked them. + */ + public function test_export_includes_test_feedback_when_explicitly_requested() { + $plugin = Contact_Form_Plugin::init(); + $real_id = $this->insert_v3_feedback_post( false ); + $test_id = $this->insert_v3_feedback_post( true ); + + $result = $plugin->get_export_feedback_data( array( $real_id, $test_id ), true ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( ' ID', $result ); + $this->assertEqualsCanonicalizing( + array( $real_id, $test_id ), + $result[' ID'], + 'When include_test_responses is true, both rows should be present in the export.' + ); + + wp_delete_post( $real_id, true ); + wp_delete_post( $test_id, true ); + } + public function test_interpersonal_data_exporter() { $post_id = Utility::create_legacy_feedback( diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php index c85eb3d69822..361410455d6f 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php @@ -131,6 +131,61 @@ public function test_process_submission_does_not_store_feedback_when_save_respon $this->assertEquals( 'no', $form->get_attribute( 'saveResponses' ), 'Form should have saveResponses set to no' ); } + /** + * Preview (test) submissions mark the feedback as a test response, skip + * Akismet, and still keep the post_status as 'publish' so the owner can + * find it in the normal inbox alongside real responses. + */ + public function test_process_submission_marks_preview_submission_as_test_feedback() { + $this->add_field_values( + array( + 'name' => 'Preview Tester', + 'email' => 'preview@example.com', + 'message' => 'This should be stored as test feedback', + ) + ); + + // Track whether Akismet filter was invoked — it must not be. + $akismet_called = 0; + add_filter( + 'jetpack_contact_form_is_spam', + function ( $is_spam ) use ( &$akismet_called ) { + ++$akismet_called; + return $is_spam; + }, + 10, + 1 + ); + + $form = new Contact_Form( + array(), + "[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type='email' required='1'/][contact-field label='Message' type='textarea' required='1'/]" + ); + $form->set_is_preview_submission( true ); + + $initial_count = count( Posts::init()->posts ); + $result = $form->process_submission(); + + $this->assertTrue( is_string( $result ), 'Form submission should be successful for preview submissions.' ); + + $final_posts = Posts::init()->posts; + $this->assertCount( $initial_count + 1, $final_posts, 'A feedback post should be created for preview submissions.' ); + + $new_post = end( $final_posts ); + $this->assertEquals( 'feedback', $new_post->post_type, 'The new post should be of type feedback.' ); + $this->assertEquals( 'publish', $new_post->post_status, 'Test feedback should be stored with publish status, not spam.' ); + + $this->assertSame( 0, $akismet_called, 'Akismet filter must not be invoked for preview (test) submissions.' ); + + // Round-trip through the Feedback reader to confirm the is_test flag is + // serialized into post_content. + $feedback = Feedback::get( $new_post->ID ); + $this->assertInstanceOf( Feedback::class, $feedback ); + $this->assertTrue( $feedback->is_test(), 'Feedback loaded from post_content should report is_test() === true.' ); + + remove_all_filters( 'jetpack_contact_form_is_spam' ); + } + /** * Test that form submissions are stored in database when saveResponses is not specified (defaults to 'yes') */ @@ -2645,6 +2700,97 @@ public function test_encode_form_to_jwt() { Constants::clear_single_constant( 'JETPACK_BLOG_TOKEN' ); } + /** + * A JWT issued while Form_Preview::is_preview_mode() is active carries + * is_test=true inside its serialized source. After decode, the form's + * source should report is_test() === true, which is how + * process_form_submission() recognizes preview submissions. + */ + public function test_jwt_source_is_flagged_as_test_when_rendered_in_preview_mode() { + Constants::set_constant( 'JETPACK_BLOG_TOKEN', 'test.token' ); + + // Flip the Form_Preview static flag for the duration of this test so + // Feedback_Source::get_current() — invoked by get_jwt() — records the + // preview context in the serialized source. + $reflection = new \ReflectionClass( Form_Preview::class ); + $preview_flag = $reflection->getProperty( 'is_preview_mode' ); + // PHP 8.1+ makes this a no-op and 8.5+ emits a deprecation notice. + if ( PHP_VERSION_ID < 80100 ) { + $preview_flag->setAccessible( true ); + } + $previous_value = $preview_flag->getValue(); + $preview_flag->setValue( null, true ); + + try { + $form = new Contact_Form( + array( + 'to' => 'preview@example.com', + 'subject' => 'preview subject', + ), + "[contact-field label='Name' type='name' required='1'/]" + ); + + $jwt = $form->get_jwt(); + + $decoded = Contact_Form::get_instance_from_jwt( $jwt ); + + $this->assertNotNull( $decoded, 'JWT should decode successfully.' ); + $this->assertTrue( + $decoded->get_source()->is_test(), + 'A JWT issued while preview mode was active should carry is_test=true in its source.' + ); + } finally { + $preview_flag->setValue( null, $previous_value ); + Constants::clear_single_constant( 'JETPACK_BLOG_TOKEN' ); + } + } + + /** + * Backward compatibility: a JWT issued before this feature shipped (or + * issued outside preview mode) has no is_test key in its source. After + * decode, the form's source should report is_test() === false, so the + * submission flows through the normal response pipeline. This matters + * because JWTs can live in cached HTML fragments across page loads. + */ + public function test_jwt_without_is_test_in_source_is_not_a_preview_submission() { + Constants::set_constant( 'JETPACK_BLOG_TOKEN', 'test.token' ); + + $form = new Contact_Form( + array( + 'to' => 'normal@example.com', + 'subject' => 'normal subject', + ), + "[contact-field label='Name' type='name' required='1'/]" + ); + + $jwt = $form->get_jwt(); + + // Sanity-check the serialized JWT does not carry is_test. We read the + // unencrypted outer claims directly — implementation detail, but it + // documents the backward-compat contract. + $raw_parts = explode( '.', $jwt ); + $raw_payload = $raw_parts[1] ?? ''; + $decoded_json = base64_decode( strtr( $raw_payload, '-_', '+/' ), true ); + $decoded_payload = $decoded_json === false ? null : json_decode( $decoded_json, true ); + $this->assertIsArray( $decoded_payload ); + $this->assertArrayHasKey( 'source', $decoded_payload ); + $this->assertArrayNotHasKey( + 'is_test', + $decoded_payload['source'], + 'Outside preview mode the source must not include an is_test key so old cached JWTs stay compatible.' + ); + + $decoded = Contact_Form::get_instance_from_jwt( $jwt ); + + $this->assertNotNull( $decoded ); + $this->assertFalse( + $decoded->get_source()->is_test(), + 'A JWT without is_test in its source must decode to a regular (non-test) submission.' + ); + + Constants::clear_single_constant( 'JETPACK_BLOG_TOKEN' ); + } + public function test_get_instance_from_jwt_uses_default_secret_when_no_token_secret() { // Ensure JETPACK_BLOG_TOKEN is not defined, so default secret is used Constants::clear_single_constant( 'JETPACK_BLOG_TOKEN' ); diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Source_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Source_Test.php index b0557a579654..16d058b4d410 100644 --- a/projects/packages/forms/tests/php/contact-form/Feedback_Source_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Source_Test.php @@ -298,6 +298,55 @@ public function test_default_page_number() { $this->assertSame( 1, $entry->get_page_number() ); } + /** + * A fresh Feedback_Source is not a test submission by default. + */ + public function test_is_test_defaults_to_false() { + $entry = new Feedback_Source( 0, 'Test Title' ); + + $this->assertFalse( $entry->is_test() ); + } + + /** + * The set_is_test setter flips the flag both ways. + */ + public function test_set_is_test_flips_the_flag() { + $entry = new Feedback_Source( 0, 'Test Title' ); + $entry->set_is_test( true ); + + $this->assertTrue( $entry->is_test() ); + + $entry->set_is_test( false ); + $this->assertFalse( $entry->is_test() ); + } + + /** + * When flagged as test, serialize includes is_test and round-trips through from_serialized. + */ + public function test_is_test_round_trips_through_serialize() { + $entry = new Feedback_Source( 0, 'Preview Title', 1 ); + $entry->set_is_test( true ); + + $serialized = $entry->serialize(); + + $this->assertArrayHasKey( 'is_test', $serialized ); + $this->assertTrue( $serialized['is_test'] ); + + $restored = Feedback_Source::from_serialized( $serialized ); + $this->assertTrue( $restored->is_test() ); + } + + /** + * Serialize omits the is_test key entirely when the flag is not set, + * so existing serialized payloads are not affected. + */ + public function test_serialize_omits_is_test_when_false() { + $entry = new Feedback_Source( 0, 'Normal Title' ); + $serialized = $entry->serialize(); + + $this->assertArrayNotHasKey( 'is_test', $serialized ); + } + /** * Test constructor overwrites ID when post is not public */