diff --git a/README.md b/README.md
index afb1383ac..374c99865 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,11 @@
**Requires at least:** 6.4
**Tested up to:** 7.0
**Requires PHP:** 7.4
-**Stable tag:** 2.11.1
+**Stable tag:** 2.12.0
**License:** GPLv2 or later
**License URI:** http://www.gnu.org/licenses/gpl-2.0.html
-AI WordPress form builder. Create contact forms, payment forms, surveys, quizzes & multi-step forms — drag & drop, no code.
+SureForms is an AI-powered WordPress form builder for creating contact forms, payment forms, surveys, quizzes, and multi-step forms without code.
## Description ##
@@ -458,6 +458,12 @@ Yes. SureForms Business includes fully functional user registration forms and lo
You can report security issues through our [Bug Bounty Program](https://brainstormforce.com/bug-bounty-program/). We collaborate with Patchstack to provide opportunities for researchers to report vulnerabilities. The Patchstack team will help validate, triage, and handle any reported security issues.
## Changelog ##
+### 2.12.0 - 24th June 2026 ###
+* New: Added action hooks around payment success, cancellation, and refund events so plugins such as SureMembers, LMS, and CRMs can grant or revoke access for both Stripe and PayPal.
+* Fix: Cancel Subscription now routes through the correct payment gateway so PayPal subscriptions cancel properly instead of always calling Stripe.
+* Fix: Forms with multiple Cloudflare Turnstile widgets now submit correctly instead of silently failing with a generic error.
+* Fix: Resolved an issue where the Stripe Payment Element failed to render on live accounts that have Bacs Direct Debit, Link, Cash App, or BNPL enabled.
+* Fix: Restored the form editor on WordPress 6.x sites running plugins that register older-style blocks such as ThirstyAffiliates, Ninja Forms, and Gravity Forms.
### 2.11.1 - 16th June 2026 ###
* Fix: Phone field auto country detection always resolved to the United States.
* Fix: Corrected the Cloudflare Turnstile "Get Keys" link.
@@ -465,10 +471,6 @@ You can report security issues through our [Bug Bounty Program](https://brainsto
### 2.11.0 - 10th June 2026 ###
* New: Added a Form Migrator to import forms from Contact Form 7, WPForms, Gravity Forms, and Ninja Forms in a single click.
* New: Added native WPML support to translate each form individually using String Packages.
-### 2.10.1 - 1st June 2026 ###
-* Improvement: Improved compatibility with older Block API versions so the SureForms editor loads reliably across WordPress environments.
-* Fix: Resolved an issue where the Textarea character counter was misaligned.
-* Fix: Resolved an issue where the {entry_id} smart tag failed to resolve in email notifications.
The full changelog is available [here](https://sureforms.com/whats-new/?utm_source=wordpress.org&utm_medium=whats_new).
## Upgrade Notice ##
diff --git a/admin/admin.php b/admin/admin.php
index 0f33e81d5..5f9dd8de2 100644
--- a/admin/admin.php
+++ b/admin/admin.php
@@ -1639,7 +1639,7 @@ public function display_srfm_rating_notice() {
'message' => $this->build_notice_markup(
esc_html__( 'Amazing! SureForms is powering your forms and submissions - let\'s keep growing together!', 'sureforms' ),
esc_html__( 'If SureForms has been helpful, would you mind taking a moment to leave a 5-star review on WordPress.org?', 'sureforms' ),
- esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/?filter=5#new-post' ),
+ esc_url( 'https://wordpress.org/support/plugin/sureforms/reviews/' ),
esc_html__( 'Rate SureForms', 'sureforms' ),
esc_html__( 'Maybe later', 'sureforms' ),
esc_html__( 'I already did', 'sureforms' ),
diff --git a/assets/js/stripe-payment.js b/assets/js/stripe-payment.js
index 51da1b498..354fa30e9 100644
--- a/assets/js/stripe-payment.js
+++ b/assets/js/stripe-payment.js
@@ -283,6 +283,15 @@ class StripePayment {
// Add type-specific configuration
if ( paymentType === 'one-time' ) {
elementsConfig.captureMethod = 'manual';
+ // Manual capture is incompatible with some account-enabled payment methods
+ // (e.g. Bacs Direct Debit, Link, Cash App, BNPL). When any of those are enabled,
+ // Stripe rejects the deferred elements/sessions request with HTTP 400 and the
+ // Payment Element fails to render — which is why the card field does not load in
+ // live mode while test mode (card-only) works. Scope the element to card so only
+ // capture-compatible methods are offered. Apple Pay / Google Pay still appear
+ // (they are surfaced through `card`); the methods dropped here could never be used
+ // with manual capture anyway, so no working checkout is lost.
+ elementsConfig.paymentMethodTypes = [ 'card' ];
}
// Create and mount payment element
diff --git a/assets/js/unminified/form-submit.js b/assets/js/unminified/form-submit.js
index 0d9754034..4b7fde776 100644
--- a/assets/js/unminified/form-submit.js
+++ b/assets/js/unminified/form-submit.js
@@ -137,7 +137,12 @@ function initializeFormHandlers() {
'button.srfm-custom-button'
);
- if ( hasHiddenClass && ! isCustomButton ) {
+ // Check if the custom button is hidden by conditional logic.
+ const isCustomButtonHidden = isCustomButton
+ ?.closest( '.srfm-custom-button-ctn' )
+ ?.classList.contains( 'hide-element' );
+
+ if ( hasHiddenClass && ( ! isCustomButton || isCustomButtonHidden ) ) {
console.warn(
'Form submission is disabled because the submit button is hidden.'
);
diff --git a/assets/js/unminified/validation.js b/assets/js/unminified/validation.js
index 32c5d0ba3..1bce70b9d 100644
--- a/assets/js/unminified/validation.js
+++ b/assets/js/unminified/validation.js
@@ -1226,9 +1226,22 @@ export const handleCaptchaValidation = (
} else if ( !! hCaptchaDiv ) {
captchaResponse = hcaptcha.getResponse();
} else if ( !! turnstileDiv ) {
- captchaResponse = turnstile.getResponse();
+ // `turnstile.getResponse()` without a widget id is unreliable when more
+ // than one Turnstile widget is rendered on the page — it can return
+ // `undefined`, which previously threw a TypeError on `.length` below and
+ // surfaced as a generic "An error occurred while submitting your form".
+ // Read the token from the hidden response field scoped to THIS form's
+ // widget, falling back to the global getResponse() for safety.
+ const turnstileResponseField = turnstileDiv.querySelector(
+ '[name="cf-turnstile-response"]'
+ );
+ captchaResponse =
+ turnstileResponseField?.value || turnstile.getResponse();
}
- const isValid = captchaResponse.length > 0;
+ // Guard against captcha libraries returning `undefined` (e.g. multiple
+ // widgets on the page) so a missing token shows the captcha error instead
+ // of throwing and failing the whole submission silently.
+ const isValid = ( captchaResponse || '' ).length > 0;
captchaErrorElement.style.display = isValid ? 'none' : 'block';
return isValid;
diff --git a/composer.lock b/composer.lock
index f852bdf35..31144877c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -63,16 +63,16 @@
},
{
"name": "brainstormforce/bsf-analytics",
- "version": "1.1.26",
+ "version": "1.1.28",
"source": {
"type": "git",
"url": "git@github.com:brainstormforce/bsf-analytics.git",
- "reference": "e9079f78d4cf7a3e85ea61e94f842e2f8e00ee68"
+ "reference": "200c7d9a93867d26945bbf2b86d83a9458e2f5b5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/brainstormforce/bsf-analytics/zipball/e9079f78d4cf7a3e85ea61e94f842e2f8e00ee68",
- "reference": "e9079f78d4cf7a3e85ea61e94f842e2f8e00ee68",
+ "url": "https://api.github.com/repos/brainstormforce/bsf-analytics/zipball/200c7d9a93867d26945bbf2b86d83a9458e2f5b5",
+ "reference": "200c7d9a93867d26945bbf2b86d83a9458e2f5b5",
"shasum": ""
},
"require-dev": {
@@ -111,10 +111,10 @@
},
"description": "Library to gather non sensitive analytics data to enhance bsf products.",
"support": {
- "source": "https://github.com/brainstormforce/bsf-analytics/tree/1.1.26",
+ "source": "https://github.com/brainstormforce/bsf-analytics/tree/1.1.28",
"issues": "https://github.com/brainstormforce/bsf-analytics/issues"
},
- "time": "2026-04-20T10:30:24+00:00"
+ "time": "2026-06-22T14:53:12+00:00"
},
{
"name": "composer/ca-bundle",
@@ -9679,5 +9679,5 @@
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/inc/abilities/abilities-registrar.php b/inc/abilities/abilities-registrar.php
index fb4a64773..8e6b5b388 100644
--- a/inc/abilities/abilities-registrar.php
+++ b/inc/abilities/abilities-registrar.php
@@ -84,6 +84,9 @@ class_exists( 'WP\MCP\Plugin' ) &&
* @return void
*/
public function register_mcp_server( $adapter ) {
+ // wp_get_abilities() is a WP 6.9+ Abilities API function. This method is only hooked
+ // when mcp_adapter_enabled() is true, which itself requires function_exists( 'wp_register_ability' ),
+ // so it is inert on WP 6.4-6.8. Plugin Check's static WP-version check reports a false positive here.
$abilities = wp_get_abilities();
$tools = [];
@@ -122,6 +125,9 @@ public function register_mcp_server( $adapter ) {
* @return void
*/
public function register_category() {
+ // wp_has_ability_category() / wp_register_ability_category() are WP 6.9+ Abilities API
+ // functions, each called only behind its own function_exists() guard, so they are inert
+ // on WP 6.4-6.8. Plugin Check's static WP-version check reports false positives here.
if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( 'sureforms' ) ) {
return;
}
@@ -198,6 +204,9 @@ public function register_abilities() {
}
// Skip abilities already registered by zipwp-mcp.
+ // wp_has_ability() is a WP 6.9+ Abilities API function, called only behind its
+ // function_exists() guard, so it is inert on WP 6.4-6.8. Plugin Check's static
+ // WP-version check reports a false positive here.
if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability->get_id() ) ) {
continue;
}
diff --git a/inc/abilities/abstract-ability.php b/inc/abilities/abstract-ability.php
index a2cf87e71..2063b728e 100644
--- a/inc/abilities/abstract-ability.php
+++ b/inc/abilities/abstract-ability.php
@@ -222,6 +222,10 @@ public function register() {
$annotations = $this->get_annotations();
+ // wp_register_ability() is a WP 6.9+ Abilities API function. It is only reached
+ // after the function_exists() guard above (and via the wp_abilities_api_init hook),
+ // so it is inert on WP 6.4-6.8. Plugin Check's static WP-version check cannot see the
+ // runtime guard, so it reports a false positive here.
wp_register_ability(
$this->id,
[
diff --git a/inc/abilities/forms/list-forms.php b/inc/abilities/forms/list-forms.php
index eef007bed..904b03906 100644
--- a/inc/abilities/forms/list-forms.php
+++ b/inc/abilities/forms/list-forms.php
@@ -183,7 +183,7 @@ private function get_entry_counts( array $form_ids ) {
$table_name = $wpdb->prefix . 'srfm_entries';
$placeholders = implode( ',', array_fill( 0, count( $form_ids ), '%d' ) );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Batch query to avoid N+1; results are not cached as they reflect real-time entry counts.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Batch query to avoid N+1; table name from $wpdb->prefix and placeholders from array_fill() (not user input); results not cached as they reflect real-time entry counts.
$results = $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are constructed from $wpdb->prefix and array_fill(), not user input.
diff --git a/inc/admin-ajax.php b/inc/admin-ajax.php
index 272f490cf..29e506a90 100644
--- a/inc/admin-ajax.php
+++ b/inc/admin-ajax.php
@@ -184,7 +184,7 @@ public function generate_data_for_suretriggers_integration() {
// Translators: %s: Form ID.
$form_name = ! empty( $form->post_title ) ? $form->post_title : sprintf( __( 'SureForms id: %s', 'sureforms' ), $form_id );
- $api_url = apply_filters( 'suretriggers_get_iframe_url', SRFM_SURETRIGGERS_INTEGRATION_BASE_URL );
+ $api_url = apply_filters( 'suretriggers_get_iframe_url', SRFM_SURETRIGGERS_INTEGRATION_BASE_URL ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- SureTriggers' own filter; the name must match SureTriggers exactly to integrate.
// This is the format of data required by SureTriggers for adding iframe in target id.
$body = [
@@ -451,10 +451,10 @@ public function download_export_file() {
}
// Output the file.
- readfile( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_readfile -- Need direct file output for download.
+ readfile( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_readfile, WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Direct file output is required to stream the download.
// Clean up the temporary file.
- unlink( $filepath );
+ wp_delete_file( $filepath );
exit;
}
diff --git a/inc/compatibility/multilingual/providers/wpml-provider.php b/inc/compatibility/multilingual/providers/wpml-provider.php
index b0a9b884b..c959d7a41 100644
--- a/inc/compatibility/multilingual/providers/wpml-provider.php
+++ b/inc/compatibility/multilingual/providers/wpml-provider.php
@@ -15,6 +15,8 @@
exit; // Exit if accessed directly.
}
+// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- This adapter exists solely to call WPML's own hooks (wpml_*); their names must match WPML exactly to integrate.
+
/**
* WPML_Provider.
*
diff --git a/inc/database/tables/entries.php b/inc/database/tables/entries.php
index 70f1c73a5..a5b2ece52 100644
--- a/inc/database/tables/entries.php
+++ b/inc/database/tables/entries.php
@@ -513,7 +513,7 @@ public static function has_duplicate_field_value( $form_id, $field_key, $field_v
$table_name = self::get_instance()->get_tablename();
$json_path = '$."' . $field_key . '"';
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-off existence check; caching not beneficial for uniqueness validation.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- One-off existence check; table name from get_tablename() (not user input); caching not beneficial for uniqueness validation.
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT 1 FROM {$table_name} WHERE form_id = %d AND status != 'trash' AND JSON_UNQUOTE(JSON_EXTRACT(form_data, %s)) = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is internally generated.
diff --git a/inc/database/tables/payments.php b/inc/database/tables/payments.php
index 756278f40..0e5d47ad1 100644
--- a/inc/database/tables/payments.php
+++ b/inc/database/tables/payments.php
@@ -1126,7 +1126,7 @@ public static function get_all_main_payments( $args = [], $set_limit = true ) {
$query = $wpdb->prepare( $query, $params );
}
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Custom table query with dynamic preparation, caching not applicable for dynamic queries.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table query with dynamic preparation; table name internal, not user input; caching not applicable for dynamic queries.
$results = $wpdb->get_results( $query, ARRAY_A );
return is_array( $results ) ? $results : [];
@@ -1183,7 +1183,7 @@ public static function get_total_main_payments_by_status( $status = 'all', $form
$query = $wpdb->prepare( $query, $params );
}
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Custom table query with dynamic preparation, caching not applicable for count operations.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table query with dynamic preparation; table name internal, not user input; caching not applicable for count operations.
$result = $wpdb->get_var( $query );
return absint( $result );
diff --git a/inc/entries.php b/inc/entries.php
index 347ef0bbc..56adfbb12 100644
--- a/inc/entries.php
+++ b/inc/entries.php
@@ -358,7 +358,7 @@ public static function export_entries( $args = [] ) {
$csv_filepath = $temp_dir . $csv_filename;
if ( file_exists( $csv_filepath ) ) {
- unlink( $csv_filepath );
+ wp_delete_file( $csv_filepath );
}
$stream = fopen( $csv_filepath, 'wb' ); // phpcs:ignore -- Using fopen to decrease the memory use.
@@ -399,7 +399,7 @@ public static function export_entries( $args = [] ) {
// Clean up CSV files.
foreach ( $csv_files as $csv_file ) {
if ( file_exists( $csv_file ) ) {
- unlink( $csv_file );
+ wp_delete_file( $csv_file );
}
}
diff --git a/inc/fields/inlinebutton-markup.php b/inc/fields/inlinebutton-markup.php
index ba4e76e16..35084ef21 100644
--- a/inc/fields/inlinebutton-markup.php
+++ b/inc/fields/inlinebutton-markup.php
@@ -208,7 +208,8 @@ public function markup() {
if ( 'cf-turnstile' === $this->captcha_security_type ) {
if ( ! empty( $this->cf_turnstile_site_key ) ) {
// Cloudflare Turnstile script.
- wp_enqueue_script( // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
+ // phpcs:disable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- Cloudflare Turnstile must be loaded from Cloudflare's servers for token verification; the version is controlled by Cloudflare.
+ wp_enqueue_script(
SRFM_SLUG . '-cf-turnstile',
'https://challenges.cloudflare.com/turnstile/v0/api.js',
[],
@@ -218,13 +219,14 @@ public function markup() {
'defer' => true,
]
);
+ // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent
} else {
Helper::render_missing_sitekey_error( 'Cloudflare Turnstile' );
}
}
if ( 'hcaptcha' === $this->captcha_security_type ) {
if ( ! empty( $this->hcaptcha_site_key ) ) {
- wp_enqueue_script( 'hcaptcha', 'https://js.hcaptcha.com/1/api.js', [], null, [ 'strategy' => 'defer' ] ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
+ wp_enqueue_script( 'hcaptcha', 'https://js.hcaptcha.com/1/api.js', [], null, [ 'strategy' => 'defer' ] ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- hCaptcha must be loaded from hCaptcha's servers for token verification; the version is controlled by hCaptcha.
} else {
Helper::render_missing_sitekey_error( 'HCaptcha' );
}
diff --git a/inc/form-submit.php b/inc/form-submit.php
index a78346cfd..fc7bff5e4 100644
--- a/inc/form-submit.php
+++ b/inc/form-submit.php
@@ -1310,7 +1310,7 @@ protected function is_known_language( string $language ): bool {
// Use WPML's filter when available — works regardless of which
// multilingual plugin is the active provider, as Polylang implements
// the same filter for compatibility.
- $active = apply_filters( 'wpml_active_languages', null, 'skip_missing=0' );
+ $active = apply_filters( 'wpml_active_languages', null, 'skip_missing=0' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own filter; the name must match WPML/Polylang exactly to integrate.
if ( is_array( $active ) && ! empty( $active ) ) {
return array_key_exists( $language, $active );
}
diff --git a/inc/generate-form-markup.php b/inc/generate-form-markup.php
index ca40c2a52..f0ba907a9 100644
--- a/inc/generate-form-markup.php
+++ b/inc/generate-form-markup.php
@@ -760,13 +760,15 @@ public static function get_google_captcha_script( $recaptcha_version, $google_ca
true,
]
);
+ // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent
?>
*/
public static function sureforms_get_integration() {
- $suretrigger_connected = apply_filters( 'suretriggers_is_user_connected', '' );
+ $suretrigger_connected = apply_filters( 'suretriggers_is_user_connected', '' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- SureTriggers' own filter; the name must match SureTriggers exactly to integrate.
$logo_sure_triggers = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers.svg' );
$logo_full = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers_full.svg' );
$logo_sure_mails = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suremails.svg' );
@@ -2163,7 +2163,7 @@ public static function apply_filters_as_array( $filter_name, $default, ...$args
}
// Apply the filter with additional arguments.
- $filtered = apply_filters( $filter_name, $default, ...$args );
+ $filtered = apply_filters( $filter_name, $default, ...$args ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Generic dynamic-filter dispatcher; the caller supplies the (already prefixed) hook name.
// Return filtered result if it's a non-empty array.
return is_array( $filtered ) && ! empty( $filtered ) ? $filtered : $default;
diff --git a/inc/lib/bsf-analytics/changelog.txt b/inc/lib/bsf-analytics/changelog.txt
index 88857c4d6..e6b2d6cf6 100644
--- a/inc/lib/bsf-analytics/changelog.txt
+++ b/inc/lib/bsf-analytics/changelog.txt
@@ -1,3 +1,9 @@
+v1.1.28 - 22-June-2026
+- Fix: Deactivation survey loaded a non-existent RTL stylesheet (`feedback.min-rtl.css`) producing a 404 in RTL locales. Registered the file suffix so the correct `feedback-rtl.min.css` is requested.
+
+v1.1.27 - 16-June-2026
+- Improvement: Added `SureDonation` slug to UTM analytics.
+
v1.1.26 - 20-April-2026
- Improvement: Switched from `Astra_Notices` to `BSF_Admin_Notices`.
diff --git a/inc/lib/bsf-analytics/modules/deactivation-survey/classes/class-deactivation-survey-feedback.php b/inc/lib/bsf-analytics/modules/deactivation-survey/classes/class-deactivation-survey-feedback.php
index c51db14ee..b3b779228 100644
--- a/inc/lib/bsf-analytics/modules/deactivation-survey/classes/class-deactivation-survey-feedback.php
+++ b/inc/lib/bsf-analytics/modules/deactivation-survey/classes/class-deactivation-survey-feedback.php
@@ -207,6 +207,7 @@ public static function load_form_styles() {
wp_enqueue_style( 'uds-feedback-style', $dir_path . 'assets/css/feedback' . $file_ext . '.css', array(), BSF_ANALYTICS_VERSION );
wp_style_add_data( 'uds-feedback-style', 'rtl', 'replace' );
+ wp_style_add_data( 'uds-feedback-style', 'suffix', $file_ext );
}
/**
diff --git a/inc/lib/bsf-analytics/modules/utm-analytics.php b/inc/lib/bsf-analytics/modules/utm-analytics.php
index c9763ec27..86479f048 100644
--- a/inc/lib/bsf-analytics/modules/utm-analytics.php
+++ b/inc/lib/bsf-analytics/modules/utm-analytics.php
@@ -49,6 +49,7 @@ class BSF_UTM_Analytics {
'surecontact',
'surecookie',
'suredash',
+ 'suredonation',
'sureforms',
'suremails',
'surerank',
diff --git a/inc/lib/bsf-analytics/version.json b/inc/lib/bsf-analytics/version.json
index 6f450d022..c25d50351 100644
--- a/inc/lib/bsf-analytics/version.json
+++ b/inc/lib/bsf-analytics/version.json
@@ -1,3 +1,3 @@
{
- "bsf-analytics-ver": "1.1.26"
+ "bsf-analytics-ver": "1.1.28"
}
diff --git a/inc/migrator/importers/ninja-importer.php b/inc/migrator/importers/ninja-importer.php
index e3523fc4c..7d62c5a6d 100644
--- a/inc/migrator/importers/ninja-importer.php
+++ b/inc/migrator/importers/ninja-importer.php
@@ -196,7 +196,7 @@ protected function get_source_forms() {
'SELECT id, title FROM %s ORDER BY id ASC',
esc_sql( $forms_table )
);
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
$rows = $wpdb->get_results( $query, ARRAY_A );
if ( ! is_array( $rows ) ) {
return [];
@@ -347,7 +347,7 @@ protected function fetch_fields( $form_id ) {
esc_sql( $fields_table ),
(int) $form_id
);
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
$rows = $wpdb->get_results( $fields_query, ARRAY_A );
if ( ! is_array( $rows ) || empty( $rows ) ) {
return [];
@@ -363,7 +363,7 @@ protected function fetch_fields( $form_id ) {
esc_sql( $field_meta_table ),
$ids_sql
);
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
$meta_rows = $wpdb->get_results( $meta_query, ARRAY_A );
$meta_by_field = [];
@@ -408,7 +408,7 @@ protected function fetch_form_meta( $form_id ) {
esc_sql( $table ),
(int) $form_id
);
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
$rows = $wpdb->get_results( $query, ARRAY_A );
$out = [];
if ( is_array( $rows ) ) {
@@ -449,7 +449,7 @@ protected function fetch_actions( $form_id ) {
esc_sql( $rels_table ),
(int) $form_id
);
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
$rows = $wpdb->get_results( $query, ARRAY_A );
if ( ! is_array( $rows ) || empty( $rows ) ) {
$this->actions_cache = [];
@@ -465,7 +465,7 @@ protected function fetch_actions( $form_id ) {
esc_sql( $meta_table ),
$ids_sql
);
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from $wpdb->prefix and IDs are int-cast/esc_sql'd; not user input.
$meta_rows = $wpdb->get_results( $meta_query, ARRAY_A );
$by_id = [];
if ( is_array( $meta_rows ) ) {
diff --git a/inc/page-builders/bricks/elements/form-widget.php b/inc/page-builders/bricks/elements/form-widget.php
index 79276ff75..76aab591c 100644
--- a/inc/page-builders/bricks/elements/form-widget.php
+++ b/inc/page-builders/bricks/elements/form-widget.php
@@ -519,15 +519,8 @@ public function render() {
- { Array.isArray( children ) &&
- Children.map( children, ( child, index ) => {
- if ( ! child ) {
- return child;
- }
- if ( ! child.key ) {
- throw new Error(
- 'props.key not found in , you must use `key` prop'
- );
- }
- return cloneElement( child, {
- index,
- isActive: child.key === currentTab,
- } );
- } ) }
+ { Children.map( children, ( child, index ) => {
+ if ( ! child ) {
+ return child;
+ }
+ if ( ! child.key ) {
+ throw new Error(
+ 'props.key not found in , you must use `key` prop'
+ );
+ }
+ return cloneElement( child, {
+ index,
+ isActive: child.key === currentTab,
+ } );
+ } ) }
>
);
};
diff --git a/src/components/page-break-settings/index.js b/src/components/page-break-settings/index.js
new file mode 100644
index 000000000..674d103f7
--- /dev/null
+++ b/src/components/page-break-settings/index.js
@@ -0,0 +1,126 @@
+/**
+ * Shared Page Break Settings controls.
+ *
+ * Reads and writes `_srfm_page_break_settings` post meta via the
+ * 'core/editor' store. Renders the same control set whether mounted in the
+ * Form Options document panel (GeneralSettings) or the Page Break block's
+ * own Inspector Controls.
+ */
+import { __ } from '@wordpress/i18n';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { SelectControl, ToggleControl } from '@wordpress/components';
+import SRFMTextControl from '@Components/text-control';
+
+const PageBreakSettings = () => {
+ const pageBreakSettings = useSelect( ( select ) => {
+ const meta =
+ select( 'core/editor' ).getEditedPostAttribute( 'meta' );
+ return meta?._srfm_page_break_settings || {};
+ } );
+
+ const { editPost } = useDispatch( 'core/editor' );
+
+ function updatePageBreakSettings( option, value ) {
+ editPost( {
+ meta: {
+ _srfm_page_break_settings: {
+ ...pageBreakSettings,
+ [ option ]: value,
+ },
+ },
+ } );
+ }
+
+ return (
+ <>
+ { pageBreakSettings?.progress_indicator_type !== 'none' && (
+ <>
+
+ updatePageBreakSettings( 'toggle_label', value )
+ }
+ />
+ { pageBreakSettings?.toggle_label && (
+
+ updatePageBreakSettings(
+ 'first_page_label',
+ value
+ )
+ }
+ isFormSpecific={ true }
+ />
+ ) }
+ >
+ ) }
+
+ updatePageBreakSettings(
+ 'progress_indicator_type',
+ value
+ )
+ }
+ __nextHasNoMarginBottom
+ />
+
+ updatePageBreakSettings( 'next_button_text', value )
+ }
+ isFormSpecific={ true }
+ />
+
+ updatePageBreakSettings( 'back_button_text', value )
+ }
+ isFormSpecific={ true }
+ />
+ >
+ );
+};
+
+export default PageBreakSettings;
diff --git a/sureforms.php b/sureforms.php
index 0ee4fda8f..2dc072243 100644
--- a/sureforms.php
+++ b/sureforms.php
@@ -7,7 +7,7 @@
* Requires PHP: 7.4
* Author: SureForms
* Author URI: https://sureforms.com/
- * Version: 2.11.1
+ * Version: 2.12.0
* License: GPLv2 or later
* Text Domain: sureforms
*
@@ -25,7 +25,7 @@
define( 'SRFM_BASENAME', plugin_basename( SRFM_FILE ) );
define( 'SRFM_DIR', plugin_dir_path( SRFM_FILE ) );
define( 'SRFM_URL', plugins_url( '/', SRFM_FILE ) );
-define( 'SRFM_VER', '2.11.1' );
+define( 'SRFM_VER', '2.12.0' );
define( 'SRFM_SLUG', 'srfm' );
// ------ ADDITIONAL CONSTANTS ------- //
define( 'SRFM_FORMS_POST_TYPE', 'sureforms_form' );
@@ -35,7 +35,7 @@
define( 'SRFM_AI_MIDDLEWARE', 'https://credits.startertemplates.com/sureforms/' );
define( 'SRFM_MIDDLEWARE_BASE_URL', 'https://api.sureforms.com/' );
define( 'SRFM_BILLING_PORTAL', 'https://billing.sureforms.com/' );
-define( 'SRFM_PRO_RECOMMENDED_VER', '2.11.1' );
+define( 'SRFM_PRO_RECOMMENDED_VER', '2.12.0' );
define( 'SRFM_SURETRIGGERS_INTEGRATION_BASE_URL', 'https://app.ottokit.com/' );
diff --git a/templates/single-form.php b/templates/single-form.php
index 34020f3a9..c5c787de5 100644
--- a/templates/single-form.php
+++ b/templates/single-form.php
@@ -12,6 +12,8 @@
exit; // Exit if accessed directly.
}
+// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Template-scoped variables consumed only within this template, not true plugin globals.
+
$srfm_custom_post_id = absint( get_the_ID() );
$srfm_form_preview = isset( $_GET['form_preview'] ) ? boolval( wp_unslash( $_GET['form_preview'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
$srfm_live_mode_data = Helper::get_instant_form_live_data();
diff --git a/tests/unit/inc/payments/stripe/test-stripe-helper.php b/tests/unit/inc/payments/stripe/test-stripe-helper.php
index 3a51a7d86..4a175e3ce 100644
--- a/tests/unit/inc/payments/stripe/test-stripe-helper.php
+++ b/tests/unit/inc/payments/stripe/test-stripe-helper.php
@@ -452,4 +452,31 @@ public function test_get_license_key_returns_empty_without_pro() {
$result = Stripe_Helper::get_license_key();
$this->assertSame( '', $result );
}
+
+ // ──────────────────────────────────────────────
+ // is_transaction_present (public) - payments table presence check
+ // ──────────────────────────────────────────────
+
+ public function test_is_transaction_present_matches_payments_table_state() {
+ global $wpdb;
+
+ $result = Stripe_Helper::is_transaction_present();
+ $this->assertIsBool( $result, 'is_transaction_present() must always return a boolean.' );
+
+ // The boolean must reflect the actual row state of the payments table:
+ // true only when at least one transaction row exists, false when the table
+ // is empty or absent. Computed from a direct count here (read-only, no mutation).
+ $table = \SRFM\Inc\Payments\Payments::get_instance()->get_tablename();
+ $expected = false;
+ if ( is_string( $table ) && '' !== $table ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
+ if ( $table_exists ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $expected = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ) > 0;
+ }
+ }
+
+ $this->assertSame( $expected, $result );
+ }
}
diff --git a/tests/unit/inc/payments/test-payment-helper.php b/tests/unit/inc/payments/test-payment-helper.php
index 09165e55a..ba1e32049 100644
--- a/tests/unit/inc/payments/test-payment-helper.php
+++ b/tests/unit/inc/payments/test-payment-helper.php
@@ -574,6 +574,125 @@ public function test_validate_amount_against_config_calc_number_fails_safe() {
$this->assertFalse( $result['valid'] );
}
+ // --- resolve_payment_user ---
+
+ public function test_resolve_payment_user_non_array_returns_zero() {
+ $this->assertSame( 0, Payment_Helper::resolve_payment_user( 'not-an-array' ) );
+ }
+
+ public function test_resolve_payment_user_empty_payment_returns_zero() {
+ $this->assertSame( 0, Payment_Helper::resolve_payment_user( [] ) );
+ }
+
+ public function test_resolve_payment_user_guest_unknown_email_returns_zero() {
+ // Guest checkout (no entry user_id) with an email matching no WP user resolves to 0.
+ $payment = [
+ 'entry_id' => 0,
+ 'customer_email' => 'no-such-user-' . uniqid() . '@example.com',
+ ];
+ $this->assertSame( 0, Payment_Helper::resolve_payment_user( $payment ) );
+ }
+
+ public function test_resolve_payment_user_matches_user_by_customer_email() {
+ // Falls back to a WP user matching customer_email when there is no entry user_id.
+ $user_id = self::factory()->user->create( [ 'user_email' => 'buyer-resolve@example.com' ] );
+ $payment = [
+ 'entry_id' => 0,
+ 'customer_email' => 'buyer-resolve@example.com',
+ ];
+ $this->assertSame( $user_id, Payment_Helper::resolve_payment_user( $payment ) );
+ }
+
+ // --- build_payment_context ---
+
+ public function test_build_payment_context_non_array_returns_defaults() {
+ $context = Payment_Helper::build_payment_context( 'not-an-array' );
+ $this->assertIsArray( $context );
+ foreach ( [ 'form_id', 'entry_id', 'user_id', 'customer_email', 'type', 'gateway', 'mode' ] as $key ) {
+ $this->assertArrayHasKey( $key, $context, "Context missing '{$key}'" );
+ }
+ $this->assertSame( 0, $context['form_id'] );
+ $this->assertSame( 0, $context['entry_id'] );
+ $this->assertSame( 0, $context['user_id'] );
+ $this->assertSame( '', $context['customer_email'] );
+ $this->assertSame( '', $context['type'] );
+ $this->assertSame( '', $context['gateway'] );
+ $this->assertSame( '', $context['mode'] );
+ }
+
+ public function test_build_payment_context_resolves_and_sanitizes_fields() {
+ $user_id = self::factory()->user->create( [ 'user_email' => 'buyer-context@example.com' ] );
+ $payment = [
+ 'form_id' => '42',
+ 'entry_id' => 0,
+ 'customer_email' => 'buyer-context@example.com',
+ 'type' => 'subscription',
+ 'gateway' => 'stripe',
+ 'mode' => 'test',
+ ];
+
+ $context = Payment_Helper::build_payment_context( $payment );
+
+ // Numeric strings are cast to int, strings sanitized, and user_id resolved via email.
+ $this->assertSame( 42, $context['form_id'] );
+ $this->assertSame( 0, $context['entry_id'] );
+ $this->assertSame( $user_id, $context['user_id'] );
+ $this->assertSame( 'buyer-context@example.com', $context['customer_email'] );
+ $this->assertSame( 'subscription', $context['type'] );
+ $this->assertSame( 'stripe', $context['gateway'] );
+ $this->assertSame( 'test', $context['mode'] );
+ }
+
+ /**
+ * An unresolved hidden-field amount source (a hidden field whose default is a
+ * non-numeric smart tag, so resolve_server_side_variable_amount() returns null and
+ * there is no Pro recompute handler) must FAIL SAFE when there is no positive
+ * minimum-amount floor. Without the guard the charge would fall through to a zero
+ * floor and accept any amount — the unauthenticated underpayment bypass flagged by
+ * the WordPress.org scanner. With a positive minimum, the documented dynamic-prefill
+ * behavior is preserved: charges at/above the floor are accepted, below are rejected.
+ */
+ public function test_validate_amount_against_config_unresolved_hidden_fails_safe() {
+ $form_id = self::factory()->post->create( [ 'post_content' => '' ] );
+
+ $config = static function ( $minimum_amount ) {
+ return [
+ 'pay123' => [
+ 'block_id' => 'pay123',
+ 'amount_type' => 'variable',
+ 'minimum_amount' => $minimum_amount,
+ 'variable_amount_field' => 'amount',
+ 'variable_amount_field_block_name' => 'srfm/hidden',
+ ],
+ 'hid456' => [
+ 'block_id' => 'hid456',
+ 'block_name' => 'srfm/hidden',
+ 'slug' => 'amount',
+ // Non-numeric smart-tag default => resolve_server_side_variable_amount() returns null.
+ 'defaultValue' => '{get_input:amount}',
+ ],
+ ];
+ };
+
+ // Submitted hidden value is present (so we pass the "value required" check) but must
+ // never be trusted as the price.
+ $form_data = [ 'srfm-hidden-hid456-lbl-QW1vdW50-amount' => '10' ];
+
+ // minimum_amount = 0: nothing safe to validate against => reject.
+ update_post_meta( $form_id, '_srfm_block_config', $config( 0 ) );
+ $no_floor = Payment_Helper::validate_amount_against_config( 'pay123', $form_id, $form_data, 10.0 );
+ $this->assertFalse( $no_floor['valid'] );
+
+ // minimum_amount = 50: dynamic-prefill preserved — at/above floor accepted, below rejected.
+ update_post_meta( $form_id, '_srfm_block_config', $config( 50 ) );
+
+ $above = Payment_Helper::validate_amount_against_config( 'pay123', $form_id, $form_data, 100.0 );
+ $this->assertTrue( $above['valid'] );
+
+ $below = Payment_Helper::validate_amount_against_config( 'pay123', $form_id, $form_data, 10.0 );
+ $this->assertFalse( $below['valid'] );
+ }
+
private function call_private_method( $object, $method_name, $parameters = [] ) {
$reflection = new \ReflectionClass( Payment_Helper::class );
$method = $reflection->getMethod( $method_name );
diff --git a/tests/unit/inc/test-admin-ajax.php b/tests/unit/inc/test-admin-ajax.php
index 7c5b59911..099f84f0d 100644
--- a/tests/unit/inc/test-admin-ajax.php
+++ b/tests/unit/inc/test-admin-ajax.php
@@ -227,4 +227,65 @@ public function test_download_export_file_path_must_be_in_temp_dir() {
$this->assertSame( 0, strpos( wp_normalize_path( $safe ), $temp_dir ) );
$this->assertNotSame( 0, strpos( wp_normalize_path( $unsafe ), $temp_dir ) );
}
+
+ // ---------------------------------------------------------------
+ // Tests for generate_data_for_suretriggers_integration() guards
+ // ---------------------------------------------------------------
+
+ /**
+ * The SureTriggers integration AJAX handler must reject requests that fail
+ * its capability, nonce, and required-parameter guards (each ends in a
+ * wp_send_json_error -> WPDieException in the test runner).
+ */
+ public function test_generate_data_for_suretriggers_integration() {
+ // 1. No logged-in user => capability check fails.
+ wp_set_current_user( 0 );
+ ob_start();
+ try {
+ $this->admin_ajax->generate_data_for_suretriggers_integration();
+ ob_end_clean();
+ $this->fail( 'Expected WPDieException for missing capability.' );
+ } catch ( \WPDieException $e ) {
+ $data = json_decode( (string) ob_get_clean(), true );
+ $this->assertIsArray( $data );
+ $this->assertFalse( $data['success'] );
+ $this->assertStringContainsString( 'permission', $data['data']['message'] );
+ }
+
+ // 2. Admin user but an invalid nonce => nonce check fails.
+ $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $admin_id );
+ $_POST['security'] = 'bad-nonce';
+ $_REQUEST['security'] = 'bad-nonce';
+ ob_start();
+ try {
+ $this->admin_ajax->generate_data_for_suretriggers_integration();
+ ob_end_clean();
+ $this->fail( 'Expected WPDieException for invalid nonce.' );
+ } catch ( \WPDieException $e ) {
+ $data = json_decode( (string) ob_get_clean(), true );
+ $this->assertFalse( $data['success'] );
+ $this->assertStringContainsString( 'nonce', strtolower( $data['data']['message'] ) );
+ }
+
+ // 3. Admin user + valid nonce, but no formId => required-param check fails.
+ $nonce = wp_create_nonce( 'suretriggers_nonce' );
+ $_POST['security'] = $nonce;
+ $_REQUEST['security'] = $nonce;
+ unset( $_POST['formId'] );
+ ob_start();
+ try {
+ $this->admin_ajax->generate_data_for_suretriggers_integration();
+ ob_end_clean();
+ $this->fail( 'Expected WPDieException for missing form id.' );
+ } catch ( \WPDieException $e ) {
+ $data = json_decode( (string) ob_get_clean(), true );
+ $this->assertFalse( $data['success'] );
+ $this->assertStringContainsString( 'Form ID', $data['data']['message'] );
+ }
+
+ // Cleanup.
+ unset( $_POST['security'], $_REQUEST['security'] );
+ wp_set_current_user( 0 );
+ }
}
diff --git a/tests/unit/inc/test-generate-form-markup.php b/tests/unit/inc/test-generate-form-markup.php
index 9fed14fb9..deb14b07b 100644
--- a/tests/unit/inc/test-generate-form-markup.php
+++ b/tests/unit/inc/test-generate-form-markup.php
@@ -294,4 +294,66 @@ public function test_get_redirect_url_query_params_disabled() {
wp_delete_post( $form_id, true );
}
+
+ /**
+ * Test get_google_captcha_script renders the widget + enqueues the right
+ * Google script per version, and falls back to the missing-sitekey error.
+ */
+ public function test_get_google_captcha_script() {
+ // Empty site key => missing-sitekey error, no widget rendered.
+ ob_start();
+ Generate_Form_Markup::get_google_captcha_script( 'v2-checkbox', '' );
+ $empty_output = ob_get_clean();
+ $this->assertStringContainsString( 'sitekey-error', $empty_output );
+ $this->assertStringNotContainsString( 'g-recaptcha', $empty_output );
+
+ // v2-checkbox => g-recaptcha widget carrying the site key, and the
+ // google-recaptcha script enqueued.
+ ob_start();
+ Generate_Form_Markup::get_google_captcha_script( 'v2-checkbox', 'test-site-key-v2' );
+ $v2_output = ob_get_clean();
+ $this->assertStringContainsString( 'g-recaptcha', $v2_output );
+ $this->assertStringContainsString( 'test-site-key-v2', $v2_output );
+ $this->assertTrue( wp_script_is( 'google-recaptcha', 'enqueued' ) );
+ wp_dequeue_script( 'google-recaptcha' );
+
+ // v3 => the dedicated v3 handle is enqueued.
+ ob_start();
+ Generate_Form_Markup::get_google_captcha_script( 'v3-reCAPTCHA', 'test-site-key-v3' );
+ ob_get_clean();
+ $this->assertTrue( wp_script_is( 'srfm-google-recaptchaV3', 'enqueued' ) );
+ wp_dequeue_script( 'srfm-google-recaptchaV3' );
+ }
+
+ /**
+ * Test get_cf_turnstile_script renders the Turnstile widget + enqueues the
+ * Cloudflare script, and falls back to the missing-sitekey error.
+ */
+ public function test_get_cf_turnstile_script() {
+ // The Turnstile enqueue passes a legacy $args shape that WordPress trunk
+ // flags via _doing_it_wrong (a PHP notice that PHPUnit would convert to a
+ // failure). Suppress just the triggered notice for the duration of this
+ // test so the rendered markup can still be asserted.
+ add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+
+ // Empty site key => missing-sitekey error, no widget.
+ ob_start();
+ Generate_Form_Markup::get_cf_turnstile_script( 'light', '' );
+ $empty_output = ob_get_clean();
+ $this->assertStringContainsString( 'sitekey-error', $empty_output );
+ $this->assertStringNotContainsString( 'cf-turnstile', $empty_output );
+
+ // Valid site key => cf-turnstile widget with the key + appearance mode,
+ // and the Cloudflare Turnstile script enqueued.
+ ob_start();
+ Generate_Form_Markup::get_cf_turnstile_script( 'dark', 'test-turnstile-key' );
+ $output = ob_get_clean();
+ $this->assertStringContainsString( 'cf-turnstile', $output );
+ $this->assertStringContainsString( 'test-turnstile-key', $output );
+ $this->assertStringContainsString( 'dark', $output );
+ $this->assertTrue( wp_script_is( SRFM_SLUG . '-cf-turnstile', 'enqueued' ) );
+ wp_dequeue_script( SRFM_SLUG . '-cf-turnstile' );
+
+ remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+ }
}
diff --git a/tests/unit/modules/gutenberg/classes/test-class-spec-filesystem.php b/tests/unit/modules/gutenberg/classes/test-class-spec-filesystem.php
new file mode 100644
index 000000000..ee5a192c7
--- /dev/null
+++ b/tests/unit/modules/gutenberg/classes/test-class-spec-filesystem.php
@@ -0,0 +1,29 @@
+assertTrue( Spec_Filesystem::get_instance()->request_filesystem_credentials() );
+ }
+}