From b18623f1cde3908a8b14dcd61ab36e807b30fdd8 Mon Sep 17 00:00:00 2001 From: KAGG Design Date: Thu, 3 Sep 2026 20:51:20 +0300 Subject: [PATCH 1/4] Version 5.3.0. --- .codeception/_config/params.php | 29 +- .github/workflows/ci.yml | 145 ++- README.md | 12 + assets/css/anti-spam.css | 12 +- assets/css/general.css | 942 ++++++++++++++- assets/images/advanced-theme-editor.jpg | Bin 0 -> 80133 bytes assets/js/admin-wordfence.js | 54 + assets/js/general.js | 1049 +++++++++++++++-- assets/js/hcaptcha-elementor-pro.js | 12 + assets/js/hcaptcha-login-signup-popup.js | 8 +- changelog.txt | 16 + composer.json | 2 + hcaptcha.php | 4 +- package.json | 14 +- readme.txt | 20 +- src/js/hcaptcha/form-interaction.js | 6 +- src/php/Abstracts/FormOwnerBase.php | 135 +++ src/php/Abstracts/LoginBase.php | 43 + src/php/Abstracts/LostPasswordBase.php | 59 +- src/php/Abstracts/RegisterBase.php | 38 + src/php/Admin/NotificationsBase.php | 2 + src/php/Admin/WhatsNew.php | 33 + src/php/Affiliates/Register.php | 2 +- src/php/AutoVerify/AutoVerify.php | 26 +- src/php/BBPress/Login.php | 77 +- src/php/BBPress/LostPassword.php | 31 +- src/php/BBPress/Register.php | 17 +- src/php/BuddyPress/Register.php | 30 + src/php/GiveWP/Base.php | 17 +- src/php/Helpers/API.php | 16 +- src/php/Helpers/HCaptcha.php | 60 +- src/php/Helpers/Playground.php | 3 - src/php/LearnDash/Register.php | 42 +- src/php/Main.php | 25 +- src/php/PasswordProtected/Protect.php | 1 + src/php/ProfileBuilder/Login.php | 60 +- src/php/Settings/AntiSpamPage.php | 43 +- src/php/Settings/General.php | 381 ++++-- src/php/WP/Login.php | 12 +- src/php/WP/LoginOut.php | 7 +- src/php/WP/Register.php | 19 +- src/php/Wordfence/General.php | 84 +- src/php/includes/functions.php | 26 +- .../assets-js-files/admin-wordfence.test.js | 74 ++ tests/js/assets-js-files/general.test.js | 491 ++++++-- .../hcaptcha-elementor-pro.test.js | 27 +- .../hcaptcha-login-signup-popup.test.js | 8 +- tests/js/hcaptcha/form-interaction.test.js | 26 + tests/manual/hcaptcha-forms-interaction.php | 650 ++++++++++ tests/php/integration/AAAMainTest.php | 51 + tests/php/integration/ACFE/FormTest.php | 184 ++- tests/php/integration/Admin/WhatsNewTest.php | 42 + .../integration/Affiliates/RegisterTest.php | 39 + .../integration/AutoVerify/AutoVerifyTest.php | 22 + tests/php/integration/Avada/FormTest.php | 69 +- tests/php/integration/BBPress/LoginTest.php | 207 +++- .../integration/BBPress/LostPasswordTest.php | 70 ++ .../php/integration/BBPress/NewTopicTest.php | 75 ++ .../php/integration/BBPress/RegisterTest.php | 90 ++ tests/php/integration/BBPress/ReplyTest.php | 93 ++ .../integration/BeaverBuilder/BaseTest.php | 28 +- .../integration/BeaverBuilder/ContactTest.php | 71 +- .../integration/BeaverBuilder/LoginTest.php | 69 +- .../Blocksy/NewsletterSubscribeTest.php | 109 +- .../integration/Blocksy/ProductReviewTest.php | 144 +-- .../php/integration/Blocksy/WaitlistTest.php | 85 +- .../BuddyPress/CreateGroupTest.php | 129 +- .../integration/BuddyPress/RegisterTest.php | 104 +- .../CF7/ReallySimpleCaptchaTest.php | 69 ++ tests/php/integration/CoBlocks/FormTest.php | 76 +- tests/php/integration/Divi/CommentTest.php | 106 +- tests/php/integration/Divi/ContactTest.php | 641 ++-------- tests/php/integration/Divi/EmailOptinTest.php | 65 +- tests/php/integration/Divi/LoginTest.php | 325 ++--- .../DownloadManager/DownloadManagerTest.php | 100 +- .../ElementorPro/HCaptchaHandlerTest.php | 163 ++- .../integration/ElementorPro/LoginTest.php | 46 +- .../integration/EssentialAddons/BaseTest.php | 23 +- .../integration/EssentialAddons/LoginTest.php | 81 +- .../EssentialAddons/RegisterTest.php | 52 +- .../integration/EssentialBlocks/FormTest.php | 83 +- tests/php/integration/FluentForm/FormTest.php | 246 ++-- tests/php/integration/Formidable/FormTest.php | 137 ++- tests/php/integration/Forminator/FormTest.php | 57 +- tests/php/integration/GiveWP/BaseTest.php | 146 ++- .../integration/GravityForms/FieldTest.php | 116 +- .../php/integration/GravityForms/FormTest.php | 498 ++++---- .../integration/HCaptchaPluginWPTestCase.php | 489 +++++++- tests/php/integration/HCaptchaWPTestCase.php | 17 +- tests/php/integration/Helpers/APITest.php | 95 ++ .../php/integration/Helpers/HCaptchaTest.php | 32 + tests/php/integration/Jetpack/BaseTest.php | 102 +- tests/php/integration/Jetpack/FormTest.php | 46 +- .../integration/Jetpack/JetpackTestCase.php | 60 + .../integration/Kadence/AdvancedFormTest.php | 80 +- tests/php/integration/Kadence/BaseTest.php | 21 +- .../integration/Kadence/BlockParserTest.php | 21 +- tests/php/integration/Kadence/FormTest.php | 35 +- .../integration/LearnDash/RegisterTest.php | 120 ++ tests/php/integration/MailPoet/FormTest.php | 119 +- tests/php/integration/Mailchimp/FormTest.php | 139 ++- .../php/integration/Maintenance/LoginTest.php | 139 +-- tests/php/integration/MetForm/FormTest.php | 217 +++- tests/php/integration/NF/FieldTest.php | 61 +- tests/php/integration/NF/NFTest.php | 10 + tests/php/integration/Otter/FormTest.php | 98 +- .../PaidMembershipsPro/CheckoutTest.php | 87 +- .../PaidMembershipsPro/LoginTest.php | 56 +- .../PasswordProtected/ProtectTest.php | 87 +- .../integration/ProfileBuilder/LoginTest.php | 72 ++ .../ProtectContent/ProtectContentTest.php | 6 +- .../integration/Sendinblue/SendinblueTest.php | 89 +- tests/php/integration/Spectra/FormTest.php | 145 ++- tests/php/integration/Stubs/CoBlocks_Form.php | 20 - .../Stubs/Elementor/Element_Base.php | 19 - .../integration/Stubs/Elementor/Plugin.php | 53 - .../Stubs/Elementor/Widget_Base.php | 19 - .../Stubs/ElementorPro/Base/Base_Widget.php | 20 - .../Modules/Forms/Widgets/Login.php | 20 - tests/php/integration/Stubs/FrmAppHelper.php | 23 - tests/php/integration/Stubs/FrmForm.php | 27 - tests/php/integration/Stubs/FrmSettings.php | 38 - tests/php/integration/Stubs/GFCommon.php | 22 - tests/php/integration/Stubs/GFForms.php | 35 - tests/php/integration/Stubs/GFFormsModel.php | 44 - tests/php/integration/Stubs/GF_Field.php | 68 -- tests/php/integration/Stubs/GF_Fields.php | 25 - .../Stubs/MailPoet/API/JSON/ResponseStub.php | 20 - tests/php/integration/UM/BaseTest.php | 17 + tests/php/integration/UM/LoginTest.php | 67 ++ tests/php/integration/UM/LostPasswordTest.php | 17 + tests/php/integration/UM/RegisterTest.php | 17 + .../integration/UltimateAddons/BaseTest.php | 24 +- .../integration/UltimateAddons/LoginTest.php | 121 +- .../UltimateAddons/RegisterTest.php | 71 +- .../integration/WC/AddPaymentMethodTest.php | 69 +- tests/php/integration/WC/CheckoutTest.php | 37 +- tests/php/integration/WC/LoginTest.php | 17 +- tests/php/integration/WC/LostPasswordTest.php | 14 +- .../php/integration/WC/OrderTrackingTest.php | 53 +- tests/php/integration/WC/RegisterTest.php | 15 +- .../integration/WC/WooCommerceTestCase.php | 64 + tests/php/integration/WP/LoginOutTest.php | 10 +- tests/php/integration/WP/LoginTest.php | 155 +++ tests/php/integration/WP/LostPasswordTest.php | 149 +++ tests/php/integration/WP/RegisterTest.php | 186 ++- .../php/integration/Wordfence/GeneralTest.php | 361 +++++- .../integration/function-mocker-bootstrap.php | 44 +- .../integration/includes/FunctionsTest.php | 34 +- .../php/integration/includes/RequestTest.php | 2 + tests/php/run-parallel.php | 635 ++++++++++ tests/php/unit/AutoVerify/AutoVerifyTest.php | 29 + tests/php/unit/HCaptchaTestCase.php | 23 +- tests/php/unit/MainTest.php | 18 + tests/php/unit/Settings/AntiSpamPageTest.php | 56 + tests/php/unit/Settings/GeneralTest.php | 129 +- tests/php/unit/includes/FunctionsTest.php | 44 +- yarn.lock | 586 +++++---- 158 files changed, 12025 insertions(+), 3536 deletions(-) create mode 100644 assets/images/advanced-theme-editor.jpg create mode 100644 assets/js/admin-wordfence.js create mode 100644 src/php/Abstracts/FormOwnerBase.php create mode 100644 src/php/Abstracts/RegisterBase.php create mode 100644 tests/js/assets-js-files/admin-wordfence.test.js create mode 100644 tests/php/integration/Affiliates/RegisterTest.php create mode 100644 tests/php/integration/Jetpack/JetpackTestCase.php create mode 100644 tests/php/integration/LearnDash/RegisterTest.php create mode 100644 tests/php/integration/ProfileBuilder/LoginTest.php delete mode 100644 tests/php/integration/Stubs/CoBlocks_Form.php delete mode 100644 tests/php/integration/Stubs/Elementor/Element_Base.php delete mode 100644 tests/php/integration/Stubs/Elementor/Plugin.php delete mode 100644 tests/php/integration/Stubs/Elementor/Widget_Base.php delete mode 100644 tests/php/integration/Stubs/ElementorPro/Base/Base_Widget.php delete mode 100644 tests/php/integration/Stubs/ElementorPro/Modules/Forms/Widgets/Login.php delete mode 100644 tests/php/integration/Stubs/FrmAppHelper.php delete mode 100644 tests/php/integration/Stubs/FrmForm.php delete mode 100644 tests/php/integration/Stubs/FrmSettings.php delete mode 100644 tests/php/integration/Stubs/GFCommon.php delete mode 100644 tests/php/integration/Stubs/GFForms.php delete mode 100644 tests/php/integration/Stubs/GFFormsModel.php delete mode 100644 tests/php/integration/Stubs/GF_Field.php delete mode 100644 tests/php/integration/Stubs/GF_Fields.php delete mode 100644 tests/php/integration/Stubs/MailPoet/API/JSON/ResponseStub.php create mode 100644 tests/php/integration/WC/WooCommerceTestCase.php create mode 100644 tests/php/run-parallel.php create mode 100644 tests/php/unit/Settings/AntiSpamPageTest.php diff --git a/.codeception/_config/params.php b/.codeception/_config/params.php index 8155dec75..5f7f3a770 100644 --- a/.codeception/_config/params.php +++ b/.codeception/_config/params.php @@ -20,12 +20,31 @@ $config = '.codeception/_config/params.github-actions.php'; if ( in_array( 'github-actions', $argv, true ) && file_exists( $config ) ) { - return include $config; + $params = include $config; +} else { + $config = '.codeception/_config/params.local.php'; + + if ( ! file_exists( $config ) ) { + die( "No valid config provided.\nPlease use 'params.example.php' as a template to create your own 'params.local.php'.\n" ); + } + + $params = include $config; } -$config = '.codeception/_config/params.local.php'; -if ( file_exists( $config ) ) { - return include $config; +$shard = getenv( 'HCAPTCHA_TEST_SHARD' ); + +if ( false !== $shard && '' !== $shard ) { + if ( ! preg_match( '/^[1-9][0-9]*$/', $shard ) ) { + throw new RuntimeException( 'HCAPTCHA_TEST_SHARD must be a positive integer.' ); + } + + $database_name = $params['DB_NAME'] . '_test_' . $shard; + + if ( 64 < strlen( $database_name ) ) { + throw new RuntimeException( 'The parallel test database name exceeds the MySQL 64-character limit.' ); + } + + $params['DB_NAME'] = $database_name; } -die( "No valid config provided.\nPlease use 'params.example.php' as a template to create your own 'params.local.php'.\n" ); +return $params; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 043620f2c..28904879f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,49 @@ jobs: path: ${{ env.wp-plugin-directory }} persist-credentials: false + - name: Detect latest Paid Memberships Pro release + id: paid_memberships_pro + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(gh api repos/strangerstudios/paid-memberships-pro/releases/latest --jq '.tag_name')" + + if [[ ! "$version" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]]; then + echo "Unexpected Paid Memberships Pro release tag: $version" >&2 + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Checkout Paid Memberships Pro + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: strangerstudios/paid-memberships-pro + ref: ${{ steps.paid_memberships_pro.outputs.version }} + path: wordpress/wp-content/plugins/paid-memberships-pro + persist-credentials: false + + - name: Detect private test plugins access + id: test_plugins + env: + HCAPTCHA_TEST_PLUGINS_TOKEN: ${{ secrets.ELEMENTOR_PRO_CI_TOKEN }} + run: | + if [[ -n "$HCAPTCHA_TEST_PLUGINS_TOKEN" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout private test plugins + if: steps.test_plugins.outputs.available == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: kagg-design/hcaptcha-test-plugins + ref: main + token: ${{ secrets.ELEMENTOR_PRO_CI_TOKEN }} + path: hcaptcha-test-plugins + persist-credentials: false + - name: Setup PHP uses: hCaptcha/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: @@ -199,6 +242,15 @@ jobs: extensions: json, mysqli, mbstring, zip ini-values: register_argc_argv=On + - name: Detect Really Simple CAPTCHA support + id: really_simple_captcha + run: | + if php -r 'exit(version_compare(PHP_VERSION, "8.3", ">=") ? 0 : 1);'; then + echo "supported=true" >> "$GITHUB_OUTPUT" + else + echo "supported=false" >> "$GITHUB_OUTPUT" + fi + - name: Install dependencies with caching uses: hCaptcha/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 with: @@ -236,13 +288,98 @@ jobs: wp db create wp core install --url="${{ env.WP_URL }}" --title="Test" --admin_user="${{ env.WP_ADMIN_USERNAME }}" --admin_password="${{ env.WP_ADMIN_PASSWORD }}" --admin_email="${{ env.WP_ADMIN_EMAIL }}" --skip-email - - name: Install plugins + - name: Install public test plugins and themes + working-directory: ${{ env.wp-directory }} + run: | + wp plugin install acf-extended bbpress blocksy-companion buddypress coblocks contact-form-7 download-manager elementor essential-addons-for-elementor-lite essential-blocks fluentform formidable forminator give jetpack kadence-blocks mailchimp-for-wp mailin mailpoet maintenance metform ninja-forms otter-blocks password-protected ultimate-addons-for-gutenberg ultimate-member woocommerce wordfence wpforms-lite wpforo + wp theme install blocksy + wp plugin is-installed acf-extended + wp plugin is-installed bbpress + wp plugin is-installed blocksy-companion + wp plugin is-installed buddypress + wp plugin is-installed coblocks + wp plugin is-installed download-manager + wp plugin is-installed essential-blocks + wp plugin is-installed formidable + wp plugin is-installed give + wp plugin is-installed kadence-blocks + wp plugin is-installed mailin + wp plugin is-installed mailpoet + wp plugin is-installed otter-blocks + wp plugin is-installed paid-memberships-pro + wp plugin is-installed password-protected + wp plugin is-installed ultimate-member + wp theme is-installed blocksy + + - name: Install Really Simple CAPTCHA + if: steps.really_simple_captcha.outputs.supported == 'true' + working-directory: ${{ env.wp-directory }} + run: | + wp plugin install really-simple-captcha + wp plugin is-installed really-simple-captcha + + - name: Install private test plugins and themes + if: steps.test_plugins.outputs.available == 'true' + working-directory: ${{ env.wp-directory }} + run: | + wp plugin install "$GITHUB_WORKSPACE/hcaptcha-test-plugins/elementor-pro-4.2.1.zip" + wp plugin install "$GITHUB_WORKSPACE/hcaptcha-test-plugins/advanced-custom-fields-pro-6.8.0.1.zip" + wp plugin install "$GITHUB_WORKSPACE/hcaptcha-test-plugins/gravityforms-3.0.2.zip" + wp plugin install "$GITHUB_WORKSPACE/hcaptcha-test-plugins/ultimate-elementor-1.39.5.zip" + wp plugin install "$GITHUB_WORKSPACE/hcaptcha-test-plugins/fusion-builder-3.11.2.zip" + wp theme install "$GITHUB_WORKSPACE/hcaptcha-test-plugins/Avada-7.11.2.zip" + wp theme install "$GITHUB_WORKSPACE/hcaptcha-test-plugins/Divi-4.27.6.zip" + wp plugin is-installed elementor-pro + wp plugin is-installed advanced-custom-fields-pro + wp plugin is-installed gravityforms + wp plugin is-installed ultimate-elementor + wp plugin is-installed fusion-builder + wp theme is-installed Avada + wp theme is-installed Divi + + - name: Install Beaver Builder + if: steps.test_plugins.outputs.available == 'true' + id: beaver_builder working-directory: ${{ env.wp-directory }} - run: wp plugin install bbpress buddypress contact-form-7 ninja-forms ultimate-member woocommerce wpforms-lite wpforo + run: | + beaver_builder_archive="$(find "$GITHUB_WORKSPACE/hcaptcha-test-plugins" -maxdepth 1 -type f \( -iname 'bb-plugin*.zip' -o -iname 'beaver-builder*.zip' \) | sort -V | tail -n 1)" + + if [[ -z "$beaver_builder_archive" ]]; then + echo "available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + wp plugin install "$beaver_builder_archive" + wp plugin is-installed bb-plugin + echo "available=true" >> "$GITHUB_OUTPUT" + + - name: Run WP tests with private test plugins + if: steps.test_plugins.outputs.available == 'true' + working-directory: ${{ env.wp-plugin-directory }} + run: | + integration_args=() + + if [[ "${{ steps.really_simple_captcha.outputs.supported }}" != "true" ]]; then + integration_args+=(--skip-group cf7-really-simple-captcha) + fi + + if [[ "${{ steps.beaver_builder.outputs.available }}" != "true" ]]; then + integration_args+=(--skip-group beaver-builder) + fi - - name: Run WP tests + composer integration:parallel -- --env github-actions "${integration_args[@]}" + + - name: Run WP tests without private test plugins + if: steps.test_plugins.outputs.available != 'true' working-directory: ${{ env.wp-plugin-directory }} - run: composer integration -- --env github-actions + run: | + integration_args=(--skip-group beaver-builder --skip-group elementor-pro --skip-group gravityforms --skip-group ultimate-addons) + + if [[ "${{ steps.really_simple_captcha.outputs.supported }}" != "true" ]]; then + integration_args+=(--skip-group cf7-really-simple-captcha) + fi + + composer integration:parallel -- --env github-actions "${integration_args[@]}" update_changelog: name: Update Changelog diff --git a/README.md b/README.md index 51bf5e971..5301a5518 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,18 @@ composer phpcs composer integration ``` +Run integration tests in parallel (up to eight detected CPU cores by default): + +``` +composer integration:parallel +composer integration:parallel -- --processes=4 +``` + +Each integration process uses a separate `_test_` database. These databases +are recreated before every parallel run, so the configured database user must be allowed +to create and drop databases. Use the sequential command for code coverage and targeted +debugging. + ## Run unit tests ``` diff --git a/assets/css/anti-spam.css b/assets/css/anti-spam.css index 993914a04..fb679b7e6 100644 --- a/assets/css/anti-spam.css +++ b/assets/css/anti-spam.css @@ -275,7 +275,7 @@ h3.closed .hcaptcha-section-header-toggle:after { grid-template-columns: repeat(2, 1fr); grid-template-areas: "login-limit login-interval" - "hide-login-errors ."; + "hide-login-errors disable-xml-rpc-auth"; gap: 0 20px; padding: 15px 20px; } @@ -294,12 +294,17 @@ h3.closed .hcaptcha-section-header-toggle:after { grid-area: hide-login-errors; } +.hcaptcha-section-login-protection + table tbody tr.hcaptcha-anti-spam-disable-xml-rpc-auth { + grid-area: disable-xml-rpc-auth; +} + .hcaptcha-section-login-protection + table tbody tr th { padding: 0 0 10px 0; width: auto; } -.hcaptcha-section-login-protection + table tbody tr.hcaptcha-anti-spam-hide-login-errors th { +.hcaptcha-section-login-protection + table tbody tr.hcaptcha-anti-spam-hide-login-errors th, +.hcaptcha-section-login-protection + table tbody tr.hcaptcha-anti-spam-disable-xml-rpc-auth th { padding: 0; } @@ -353,7 +358,8 @@ h3.closed .hcaptcha-section-header-toggle:after { grid-template-areas: "login-limit" "login-interval" - "hide-login-errors"; + "hide-login-errors" + "disable-xml-rpc-auth"; } .hcaptcha-section-login-protection + table tbody tr td { diff --git a/assets/css/general.css b/assets/css/general.css index 17a028483..bbc45877d 100644 --- a/assets/css/general.css +++ b/assets/css/general.css @@ -18,7 +18,15 @@ } #hcaptcha-options .h-captcha { - margin-bottom: 0; + margin-bottom: 8px; +} + +#hcaptcha-options .h-captcha[data-theme="custom"][style*="--hcaptcha-theme-editor-background"]:before { + background-color: var(--hcaptcha-theme-editor-background); +} + +#hcaptcha-options .h-captcha[data-size="normal"] iframe { + height: 77px !important; } #hcaptcha-options.hcaptcha-general h3 { @@ -180,9 +188,7 @@ h3.closed .hcaptcha-section-header-toggle:after { .hcaptcha-section-custom + table tbody { display: grid; grid-template-columns: repeat(4, 1fr); - grid-template-areas: - "custom-themes custom-themes config-params config-params" - "custom-prop custom-value config-params config-params"; + grid-template-areas: "custom-themes custom-themes config-params config-params"; gap: 10px 20px; padding: 15px 20px; } @@ -192,46 +198,833 @@ h3.closed .hcaptcha-section-header-toggle:after { } .hcaptcha-section-custom + table tbody tr.hcaptcha-general-config-params { + display: grid; grid-area: config-params; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 20px; } -.hcaptcha-section-custom + table tbody tr.hcaptcha-general-custom-prop { - grid-area: custom-prop; +.hcaptcha-section-custom + table tbody tr.hcaptcha-general-config-params > th { + grid-column: 1; + width: auto; + padding: 0 0 10px; } -.hcaptcha-section-custom + table tbody tr.hcaptcha-general-custom-value { - grid-area: custom-value; +.hcaptcha-section-custom + table tbody tr.hcaptcha-general-config-params > td { + display: block; + grid-column: 1 / -1; + width: 100%; + padding: 0; } -.hcaptcha-section-custom + table tbody tr th { - padding: 0 0 10px 0; +.hcaptcha-section-custom + table tbody tr.hcaptcha-general-custom-themes th { width: auto; + padding: 0 0 10px; } .hcaptcha-section-custom + table tbody tr.hcaptcha-general-custom-themes td { width: max-content; } -.hcaptcha-section-custom + table tbody tr.hcaptcha-general-config-params td { - width: auto; +.hcaptcha-theme-editor-header, +.hcaptcha-theme-editor-title, +.hcaptcha-theme-editor-window-actions, +.hcaptcha-theme-editor-fields-header, +.hcaptcha-theme-editor-json-header, +.hcaptcha-theme-editor-preview-header, +.hcaptcha-theme-editor-json-actions, +.hcaptcha-theme-editor-footer, +.hcaptcha-theme-editor-footer-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } -.hcaptcha-section-custom + table tbody tr td textarea { - width: 100%; +.hcaptcha-theme-editor-launcher { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + align-items: start; +} + +.hcaptcha-theme-editor-launcher [data-theme-editor-open] { + justify-self: start; +} + +.hcaptcha-theme-editor-launcher .hcaptcha-theme-editor-dirty { + display: flex; + align-items: center; + min-height: 40px; +} + +.hcaptcha-theme-editor-launcher .hcaptcha-theme-editor-dirty[hidden] { + display: none; +} + +.hcaptcha-theme-editor-window-actions, +.hcaptcha-theme-editor-footer-actions { + justify-content: flex-end; +} + +.hcaptcha-theme-editor { + position: fixed; + z-index: 100100; + top: 64px; + right: 24px; + display: flex; + flex-direction: column; + width: 1000px; + height: 730px; + min-width: 860px; + min-height: min(730px, calc(100vh - 80px)); + max-width: calc(100vw - 32px); + max-height: calc(100vh - 80px); + overflow: hidden; + border: 1px solid #8c8f94; + box-shadow: 0 12px 42px rgb(0 0 0 / 28%); + background: #fff; + box-sizing: border-box; + resize: both; +} + +.hcaptcha-theme-editor[hidden] { + display: none; +} + +.hcaptcha-theme-editor-header { + flex: 0 0 auto; + min-height: 54px; + padding: 8px 10px 8px 14px; + color: #1d2327; + background: #f6f7f7; + cursor: move; + user-select: none; +} + +.hcaptcha-theme-editor-title { + justify-content: flex-start; } -.hcaptcha-section-custom + table tbody tr td select { +.hcaptcha-theme-editor-icon { + flex: 0 0 32px; + width: 32px; + height: 32px; +} + +.hcaptcha-theme-editor-title strong, +.hcaptcha-theme-editor-title span { + display: block; +} + +.hcaptcha-theme-editor-title span { + margin-top: 1px; + color: #646970; + font-size: 11px; +} + +.hcaptcha-theme-editor-close { + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: 2px; + color: #50575e; + background: transparent; + cursor: pointer; +} + +.hcaptcha-theme-editor-close:hover { + color: #1d2327; + background: #dcdcde; +} + +.hcaptcha-theme-editor-json-header .description { + margin: 3px 0 0; +} + +.hcaptcha-theme-editor-dirty { + color: #646970; + white-space: nowrap; +} + +.hcaptcha-theme-editor-launcher .hcaptcha-theme-editor-dirty.is-dirty { + color: #d63638; +} + +.hcaptcha-theme-editor-tabs { + flex: 0 0 auto; + display: flex; + gap: 20px; + padding: 0 18px; + border-top: 1px solid #dcdcde; + border-bottom: 1px solid #c3c4c7; +} + +.hcaptcha-theme-editor-pro-notice { + flex: 0 0 auto; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 18px 10px 14px; + border-bottom: 1px solid #dcdcde; + border-left: 4px solid #dba617; + color: #1d2327; + background: #fcf9e8; +} + +.hcaptcha-theme-editor-pro-notice .dashicons { + flex: 0 0 20px; + margin-top: 1px; + color: #996800; +} + +.hcaptcha-theme-editor-pro-notice p { + margin: 0; +} + +.hcaptcha-theme-editor-pro-notice strong { + display: inline; + margin-right: 4px; +} + +.hcaptcha-theme-editor-tab { + margin-bottom: -1px; + padding: 11px 1px 9px; + border: 0; + border-bottom: 3px solid transparent; + color: #646970; + background: transparent; + cursor: pointer; +} + +.hcaptcha-theme-editor-tab:hover { + color: #135e96; +} + +.hcaptcha-theme-editor-tab.is-active { + border-bottom-color: #2271b1; + color: #1d2327; +} + +.hcaptcha-theme-editor-tab:focus-visible, +.hcaptcha-theme-editor-nav-button:focus-visible, +.hcaptcha-theme-editor-preview-background button:focus-visible, +.hcaptcha-theme-editor-preview-switch button:focus-visible, +.hcaptcha-theme-editor-close:focus-visible { + outline: 2px solid #72aee6; + outline-offset: 1px; +} + +.hcaptcha-theme-editor-workspace { + flex: 1 1 auto; + display: grid; + grid-template-columns: minmax(0, 1fr) 390px; + min-height: 0; + overflow: hidden; +} + +.hcaptcha-theme-editor-panes { + min-width: 0; + min-height: 0; + border-right: 1px solid #c3c4c7; +} + +.hcaptcha-theme-editor-pane[data-theme-editor-pane="visual"] { + display: grid; + grid-template-columns: minmax(140px, 170px) minmax(0, 1fr); + height: 100%; + min-height: 0; +} + +.hcaptcha-theme-editor-nav { + min-height: 0; + padding: 12px 9px; + overflow-y: auto; + border-right: 1px solid #dcdcde; + background: #f6f7f7; +} + +.hcaptcha-theme-editor-nav > [data-theme-editor-component-nav] { + display: block; +} + +.hcaptcha-theme-editor-nav-label { + display: block; + padding: 9px 9px 5px; + color: #646970; + font-size: 11px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; +} + +.hcaptcha-theme-editor-nav-button { + display: block; width: 100%; + padding: 7px 9px; + border: 0; + border-radius: 2px; + color: #1d2327; + text-align: left; + background: transparent; + cursor: pointer; } -.hcaptcha-section-custom + table tbody tr td select option:disabled { +.hcaptcha-theme-editor-nav-button:hover { + color: #135e96; background: #f0f0f1; - color: #2c3338 } -.hcaptcha-section-custom + table tbody tr td input { +.hcaptcha-theme-editor-nav-button.is-active { + color: #fff; + background: #2271b1; +} + +.hcaptcha-theme-editor-fields { + min-width: 0; + min-height: 0; + padding: 18px; + overflow-y: auto; +} + +.hcaptcha-theme-editor-fields-header { + align-items: flex-start; + margin-bottom: 14px; +} + +.hcaptcha-theme-editor-fields-header .description { + margin: 3px 0 0; +} + +.hcaptcha-theme-editor-mode, +.hcaptcha-theme-editor-color-row { + display: grid; + grid-template-columns: minmax(120px, 1fr) auto; + align-items: center; + gap: 14px; + padding: 11px 0; + border-top: 1px solid #dcdcde; +} + +.hcaptcha-theme-editor-mode { + border-top: 0; +} + +.hcaptcha-theme-editor-mode select { + min-width: 132px; +} + +.hcaptcha-theme-editor-color-label { + display: block; + font-weight: 600; +} + +.hcaptcha-theme-editor-color-path { + display: block; + margin-top: 2px; + color: #646970; + font-family: Consolas, Monaco, monospace; + font-size: 11px; +} + +.hcaptcha-theme-editor-color-controls { + display: flex; + align-items: center; + gap: 7px; +} + +.hcaptcha-theme-editor-color { + width: 36px; + height: 32px; + padding: 2px; + border: 1px solid #8c8f94; + border-radius: 2px; + background: #fff; + cursor: pointer; +} + +.hcaptcha-theme-editor-hex { + width: 88px; + min-height: 32px; + font-family: Consolas, Monaco, monospace; + font-size: 12px; + text-transform: uppercase; +} + +.hcaptcha-theme-editor-hex[aria-invalid="true"] { + border-color: #d63638; + box-shadow: 0 0 0 1px #d63638; +} + +.hcaptcha-theme-editor-pane[data-theme-editor-pane="json"] { + height: 100%; + min-height: 0; + padding: 18px; + box-sizing: border-box; + overflow: auto; +} + +.hcaptcha-theme-editor-pane[hidden] { + display: none; +} + +.hcaptcha-theme-editor-json-header { + align-items: flex-start; + margin-bottom: 12px; +} + +.hcaptcha-theme-editor-json-actions { + justify-content: flex-end; +} + +.hcaptcha-theme-editor-json-status { + display: inline-flex; + align-items: center; + gap: 6px; + color: #008a20; + white-space: nowrap; +} + +.hcaptcha-theme-editor-json-status:before { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + content: ''; +} + +.hcaptcha-theme-editor-json-status.is-invalid { + color: #d63638; +} + +.hcaptcha-theme-editor-pane textarea { + display: block; width: 100%; + height: calc(100% - 74px); + min-height: 250px; + padding: 12px; + resize: vertical; + font-family: Consolas, Monaco, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre; + tab-size: 2; +} + +.hcaptcha-theme-editor-pane textarea[aria-invalid="true"] { + border-color: #d63638; + box-shadow: 0 0 0 1px #d63638; +} + +.hcaptcha-theme-editor-json-error { + min-height: 18px; + margin: 6px 0 0; + color: #d63638; +} + +.hcaptcha-theme-editor-preview { + min-width: 0; + min-height: 0; + padding: 14px; + overflow-y: auto; + background: #f6f7f7; + box-sizing: border-box; +} + +.hcaptcha-theme-editor-preview-header { + margin-bottom: 14px; +} + +.hcaptcha-theme-editor-preview-background { + display: inline-flex; + overflow: hidden; + border: 1px solid #8c8f94; + border-radius: 2px; +} + +.hcaptcha-theme-editor-preview-background button { + padding: 4px 9px; + border: 0; + border-left: 1px solid #8c8f94; + color: #1d2327; + background: #fff; + cursor: pointer; +} + +.hcaptcha-theme-editor-preview-background button:first-child { + border-left: 0; +} + +.hcaptcha-theme-editor-preview-background button.is-active { + color: #fff; + background: #2271b1; +} + +.hcaptcha-theme-editor-preview-switch { + display: grid; + grid-template-columns: 1fr 1fr; + margin-bottom: 10px; + padding: 3px; + border-radius: 4px; + background: #dcdcde; +} + +.hcaptcha-theme-editor-preview-switch button { + padding: 6px 8px; + border: 0; + border-radius: 3px; + color: #50575e; + background: transparent; + cursor: pointer; +} + +.hcaptcha-theme-editor-preview-switch button.is-active { + color: #1d2327; + box-shadow: 0 1px 2px rgb(0 0 0 / 12%); + background: #fff; +} + +.hcaptcha-theme-editor-preview-stage { + display: grid; + place-items: center; + min-height: 365px; + padding: 12px 8px; + border: 1px dashed #8c8f94; + background-color: #eef1f4; + background-image: + linear-gradient(45deg, #e2e6ea 25%, transparent 25%), + linear-gradient(-45deg, #e2e6ea 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e2e6ea 75%), + linear-gradient(-45deg, transparent 75%, #e2e6ea 75%); + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + background-size: 16px 16px; + box-sizing: border-box; + --hcap-palette-heading: #555; + --hcap-palette-body: #555; + --hcap-palette-primary: #00838f; + --hcap-checkbox-fill: #fafafa; + --hcap-checkbox-border: #e0e0e0; + --hcap-widget-checkbox-fill: #fafafa; + --hcap-widget-checkbox-border: #555; + --hcap-widget-label: #333; + --hcap-widget-brand: #555; + --hcap-challenge-fill: #fafafa; + --hcap-challenge-border: #e0e0e0; + --hcap-modal-fill: #fff; + --hcap-modal-border: #e0e0e0; + --hcap-breadcrumb-fill: #f5f5f5; + --hcap-breadcrumb-active: #00838f; + --hcap-button-fill: #fff; + --hcap-button-icon: #555; + --hcap-list-fill: #fff; + --hcap-list-border: #d7d7d7; + --hcap-list-item-fill: #fff; + --hcap-list-item-line: #f5f5f5; + --hcap-list-item-text: #555; + --hcap-list-item-selected: #e0e0e0; + --hcap-radio-file: #f5f5f5; + --hcap-radio-border: #919191; + --hcap-radio-check: #00838f; + --hcap-task-fill: #f5f5f5; + --hcap-task-selected: #00838f; + --hcap-task-report: #eb5757; + --hcap-prompt-fill: #00838f; + --hcap-prompt-border: #00838f; + --hcap-prompt-text: #fff; + --hcap-skip-fill: #919191; + --hcap-skip-border: #919191; + --hcap-skip-text: #fff; + --hcap-verify-fill: #00838f; + --hcap-verify-border: #00838f; + --hcap-verify-text: #fff; + --hcap-slider-bar: #c4c4c4; + --hcap-slider-handle: #0f8390; +} + +.hcaptcha-theme-editor-preview-stage.is-dark { + background-color: #252a30; + background-image: + linear-gradient(45deg, #30363d 25%, transparent 25%), + linear-gradient(-45deg, #30363d 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #30363d 75%), + linear-gradient(-45deg, transparent 75%, #30363d 75%); +} + +.hcaptcha-theme-editor-preview-stage [hidden] { + display: none; +} + +.hcaptcha-theme-editor-preview-stage [data-theme-preview-pane] { + grid-area: 1 / 1; +} + +.hcaptcha-theme-editor-preview-stage .hcaptcha-theme-mock-widget[hidden] { + display: grid; + visibility: hidden; +} + +.hcaptcha-theme-editor-preview-stage .hcaptcha-theme-mock-challenge[hidden] { + display: block; + visibility: hidden; +} + +.hcaptcha-theme-editor-preview-note { + margin: 8px 0 0; + text-align: center; +} + +.hcaptcha-theme-editor-footer { + flex: 0 0 auto; + justify-content: flex-end; + padding: 12px 18px; + border-top: 1px solid #c3c4c7; +} + +.hcaptcha-theme-editor-launcher.is-disabled { + opacity: .55; +} + +.hcaptcha-theme-mock-widget { + display: grid; + grid-template-columns: 60px minmax(0, 170px) 65px; + align-items: center; + gap: 0; + width: 302px; + max-width: 100%; + height: 76px; + padding: 0; + border: 1px solid var(--hcap-checkbox-border); + border-radius: 4px; + color: var(--hcap-widget-label); + background: var(--hcap-checkbox-fill); + box-sizing: border-box; + font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, "Helvetica Neue", Arial, sans-serif; +} + +.hcaptcha-theme-mock-checkbox { + place-self: center; + width: 30px; height: 30px; + border: 1px solid var(--hcap-widget-checkbox-border); + border-radius: 4px; + background: var(--hcap-widget-checkbox-fill); + box-sizing: border-box; +} + +.hcaptcha-theme-mock-widget-label { + font-size: 14px; + font-weight: 400; + line-height: normal; +} + +.hcaptcha-theme-mock-brand { + position: relative; + align-self: stretch; + width: 65px; + color: var(--hcap-widget-brand); +} + +.hcaptcha-theme-mock-brand small { + position: absolute; + top: 56px; + left: 0; + width: 65px; + margin: 0; + font-size: 8px; + font-weight: 500; + line-height: 8px; + text-align: center; + white-space: nowrap; +} + +.hcaptcha-theme-mock-logo { + position: absolute; + top: 6px; + left: 10.5px; + display: block; + width: 44px; + height: 50px; + margin: 0; +} + +.hcaptcha-theme-mock-challenge { + width: 286px; + max-width: 100%; + padding: 8px; + border: 1px solid var(--hcap-modal-border); + border-radius: 4px; + color: var(--hcap-palette-body); + background: var(--hcap-modal-fill); + box-shadow: 0 2px 8px rgb(0 0 0 / 18%); + box-sizing: border-box; +} + +.hcaptcha-theme-mock-prompt { + display: flex; + align-items: flex-end; + min-height: 70px; + padding: 12px; + border: 1px solid var(--hcap-prompt-border); + color: var(--hcap-prompt-text); + font-size: 15px; + font-weight: 600; + line-height: 1.25; + background: var(--hcap-prompt-fill); + box-sizing: border-box; +} + +.hcaptcha-theme-mock-breadcrumbs { + display: flex; + justify-content: center; + gap: 5px; + padding: 7px 0; +} + +.hcaptcha-theme-mock-breadcrumbs span { + width: 18px; + height: 4px; + border-radius: 2px; + background: var(--hcap-breadcrumb-fill); +} + +.hcaptcha-theme-mock-breadcrumbs span.is-active { + background: var(--hcap-breadcrumb-active); +} + +.hcaptcha-theme-mock-tasks { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + padding: 5px; + border: 1px solid var(--hcap-challenge-border); + background: var(--hcap-challenge-fill); +} + +.hcaptcha-theme-mock-tasks span { + height: 47px; + border: 2px solid transparent; + background-color: var(--hcap-task-fill); + background-image: + linear-gradient(135deg, transparent 48%, rgb(80 90 100 / 20%) 49% 54%, transparent 55%), + radial-gradient(circle at 65% 35%, rgb(0 131 143 / 28%) 0 8px, transparent 9px); + box-sizing: border-box; +} + +.hcaptcha-theme-mock-tasks span.is-hovered { + filter: brightness(.94); +} + +.hcaptcha-theme-mock-tasks span.is-selected { + border-color: var(--hcap-task-selected); +} + +.hcaptcha-theme-mock-tasks span.is-reported { + border-color: var(--hcap-task-report); +} + +.hcaptcha-theme-mock-answer { + display: grid; + grid-template-columns: auto 1fr 64px; + align-items: center; + gap: 7px; + margin-top: 6px; + padding: 7px; + border: 1px solid var(--hcap-list-border); + color: var(--hcap-list-item-text); + font-size: 10px; + background: var(--hcap-list-fill); + box-shadow: inset 0 -1px var(--hcap-list-item-line); +} + +.hcaptcha-theme-mock-answer-text { + padding: 3px 5px; + background: var(--hcap-list-item-fill); + box-shadow: inset 2px 0 var(--hcap-list-item-selected); +} + +.hcaptcha-theme-mock-radio { + position: relative; + width: 14px; + height: 14px; + border: 1px solid var(--hcap-radio-border); + border-radius: 50%; + background: var(--hcap-radio-file); +} + +.hcaptcha-theme-mock-radio:after { + position: absolute; + top: 3px; + left: 3px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--hcap-radio-check); + content: ''; +} + +.hcaptcha-theme-mock-slider { + position: relative; + height: 4px; + border-radius: 2px; + background: var(--hcap-slider-bar); +} + +.hcaptcha-theme-mock-slider i { + position: absolute; + top: -4px; + left: 32px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--hcap-slider-handle); +} + +.hcaptcha-theme-mock-challenge-footer { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 6px; + padding-top: 8px; +} + +.hcaptcha-theme-mock-tools { + display: flex; + gap: 4px; +} + +.hcaptcha-theme-mock-tools span { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 1px solid var(--hcap-modal-border); + border-radius: 3px; + color: var(--hcap-button-icon); + background: var(--hcap-button-fill); +} + +.hcaptcha-theme-mock-skip, +.hcaptcha-theme-mock-verify { + padding: 6px 9px; + border-style: solid; + border-width: 1px; + border-radius: 3px; + font-size: 11px; +} + +.hcaptcha-theme-mock-skip { + border-color: var(--hcap-skip-border); + color: var(--hcap-skip-text); + background: var(--hcap-skip-fill); +} + +.hcaptcha-theme-mock-verify { + border-color: var(--hcap-verify-border); + color: var(--hcap-verify-text); + background: var(--hcap-verify-fill); } /* Section Enterprise */ @@ -387,6 +1180,42 @@ h3.closed .hcaptcha-section-header-toggle:after { width: max-content; } +@media (max-width: 900px) { + .hcaptcha-theme-editor { + top: 52px; + right: 8px; + left: 8px; + width: auto; + height: calc(100vh - 64px); + min-width: 0; + min-height: 420px; + max-width: none; + max-height: none; + resize: none; + } + + .hcaptcha-theme-editor-workspace { + grid-template-columns: 1fr; + overflow-y: auto; + } + + .hcaptcha-theme-editor-panes { + min-height: 420px; + border-right: 0; + border-bottom: 1px solid #c3c4c7; + } + + .hcaptcha-theme-editor-preview { + grid-row: 1; + min-height: auto; + overflow-y: visible; + } + + .hcaptcha-theme-editor-preview-stage { + min-height: 220px; + } +} + @media (max-width: 600px) { .hcaptcha-section-keys + table tbody { grid-template-columns: repeat(2, 1fr) !important; @@ -411,15 +1240,80 @@ h3.closed .hcaptcha-section-header-toggle:after { } .hcaptcha-section-custom + table tbody { - grid-template-columns: repeat(2, 1fr) !important; + grid-template-columns: 1fr; grid-template-areas: - "custom-themes custom-themes" - "custom-prop custom-value" - "config-params config-params"; + "custom-themes" + "config-params"; + gap: 16px; + padding: 15px 12px 18px; } - .hcaptcha-section-custom + table tbody tr td { - width: unset !important; + .hcaptcha-theme-editor-json-header, + .hcaptcha-theme-editor-footer { + align-items: flex-start; + flex-direction: column; + } + + .hcaptcha-theme-editor-footer-actions { + width: 100%; + justify-content: space-between; + } + + .hcaptcha-theme-editor-title span:not(.screen-reader-text), + .hcaptcha-theme-editor-window-actions .hcaptcha-theme-editor-dirty { + display: none; + } + + .hcaptcha-theme-editor-dirty { + white-space: normal; + } + + .hcaptcha-theme-editor-pane[data-theme-editor-pane="visual"] { + grid-template-columns: 1fr; + } + + .hcaptcha-theme-editor-nav { + display: flex; + gap: 4px; + max-height: none; + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid #dcdcde; + } + + .hcaptcha-theme-editor-nav > [data-theme-editor-component-nav] { + display: flex; + gap: 4px; + } + + .hcaptcha-theme-editor-nav-label { + display: none; + } + + .hcaptcha-theme-editor-nav-button { + width: auto; + white-space: nowrap; + } + + .hcaptcha-theme-editor-fields { + max-height: none; + } + + .hcaptcha-theme-mock-challenge { + width: 100%; + } + + .hcaptcha-theme-editor-mode, + .hcaptcha-theme-editor-color-row { + grid-template-columns: 1fr; + } + + .hcaptcha-theme-editor-color-controls { + justify-content: flex-start; + } + + .hcaptcha-theme-editor-json-actions { + justify-content: flex-start; } .hcaptcha-section-enterprise + table tbody { diff --git a/assets/images/advanced-theme-editor.jpg b/assets/images/advanced-theme-editor.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6146cdbd5d664cc6844d8746deec701d41ba1d92 GIT binary patch literal 80133 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 NumX4~?(+A^{{hQM>SF)^ literal 0 HcmV?d00001 diff --git a/assets/js/admin-wordfence.js b/assets/js/admin-wordfence.js new file mode 100644 index 000000000..1f190dcc1 --- /dev/null +++ b/assets/js/admin-wordfence.js @@ -0,0 +1,54 @@ +/* global jQuery, HCaptchaWordfenceObject */ +// noinspection ES6ShorthandObjectProperty + +/** + * @param HCaptchaWordfenceObject.noticeLabel + * @param HCaptchaWordfenceObject.noticeDescription + */ + +const wordfence = window.hCaptchaWordfence || ( function( document, window, $ ) { + /** + * Public functions and properties. + * + * @type {Object} + */ + const app = { + init() { + $( app.ready ); + }, + + ready() { + if ( ! app.getLocationHref().includes( 'page=WFLS' ) ) { + return; + } + + const $settings = $( '#wfls-hcaptcha-settings' ); + + if ( ! $settings.length ) { + return; + } + + const notice = document.createElement( 'ul' ); + const noticeItem = document.createElement( 'li' ); + const noticeLabel = document.createElement( 'strong' ); + const noticeDescription = document.createElement( 'p' ); + + notice.classList.add( 'wfls-block-list', 'hcaptcha-wordfence-notice' ); + noticeLabel.textContent = HCaptchaWordfenceObject.noticeLabel; + noticeDescription.innerHTML = HCaptchaWordfenceObject.noticeDescription; + noticeItem.append( noticeLabel, noticeDescription ); + notice.append( noticeItem ); + $settings.find( '.wfls-block-content' ).empty().append( notice ); + }, + + getLocationHref() { + return window.location.href; + }, + }; + + return app; +}( document, window, jQuery ) ); + +window.hCaptchaWordfence = wordfence; + +wordfence.init(); diff --git a/assets/js/general.js b/assets/js/general.js index bb8a0758d..a5bcb03ae 100644 --- a/assets/js/general.js +++ b/assets/js/general.js @@ -2,6 +2,7 @@ /** * @param HCaptchaGeneralObject.ajaxUrl + * @param HCaptchaGeneralObject.activeState * @param HCaptchaGeneralObject.badJSONError * @param HCaptchaGeneralObject.checkConfigAction * @param HCaptchaGeneralObject.checkConfigNonce @@ -9,6 +10,13 @@ * @param HCaptchaGeneralObject.checkingConfigMsg * @param HCaptchaGeneralObject.completeHCaptchaContent * @param HCaptchaGeneralObject.completeHCaptchaTitle + * @param HCaptchaGeneralObject.configMustBeObject + * @param HCaptchaGeneralObject.focusState + * @param HCaptchaGeneralObject.hexValue + * @param HCaptchaGeneralObject.hoverState + * @param HCaptchaGeneralObject.invalidJSON + * @param HCaptchaGeneralObject.lastValidPreview + * @param HCaptchaGeneralObject.mainState * @param HCaptchaGeneralObject.modeLive * @param HCaptchaGeneralObject.modeTestEnterpriseBotDetected * @param HCaptchaGeneralObject.modeTestEnterpriseBotDetectedSiteKey @@ -16,7 +24,11 @@ * @param HCaptchaGeneralObject.modeTestEnterpriseSafeEndUserSiteKey * @param HCaptchaGeneralObject.modeTestPublisher * @param HCaptchaGeneralObject.modeTestPublisherSiteKey + * @param HCaptchaGeneralObject.reportState + * @param HCaptchaGeneralObject.selectedState * @param HCaptchaGeneralObject.siteKey + * @param HCaptchaGeneralObject.unsavedChanges + * @param HCaptchaGeneralObject.validJSON */ /* eslint-disable no-console */ @@ -41,17 +53,30 @@ const general = function( $ ) { const $language = $( '[name="hcaptcha_settings[language]"]' ); const $mode = $( '[name="hcaptcha_settings[mode]"]' ); const $customThemes = $( '[name="hcaptcha_settings[custom_themes][]"]' ); - const $customProp = $( '.hcaptcha-general-custom-prop select' ); - const $customValue = $( '.hcaptcha-general-custom-value input' ); const $configParams = $( '[name="hcaptcha_settings[config_params]"]' ); + const $themeEditorLauncher = $( '.hcaptcha-theme-editor-launcher' ); + const $themeEditor = $( '.hcaptcha-theme-editor' ); + const themeEditorPreviewOnly = 'true' === $themeEditor.attr( 'data-theme-editor-preview-only' ); + const $themeEditorStatus = $( '.hcaptcha-theme-editor-dirty' ); + const $themeEditorJSONStatus = $themeEditor.find( '.hcaptcha-theme-editor-json-status' ); + const $themeEditorJSONError = $themeEditor.find( '.hcaptcha-theme-editor-json-error' ); + const $themeEditorPreviewNote = $themeEditor.find( '[data-theme-editor-preview-note]' ); + const $themeEditorPreviewDefaultContent = $themeEditorPreviewNote.contents().clone(); + const $themeEditorOpen = $themeEditorLauncher.find( '[data-theme-editor-open]' ); const $enterpriseInputs = $( '.hcaptcha-section-enterprise + table input' ); const $recaptchaCompatOff = $( '[name="hcaptcha_settings[recaptcha_compat_off][]"]' ); const $submit = $form.find( '#submit' ); const modes = {}; - const dataErrorBgColor = '#ffabaf'; let siteKeyInitVal = $siteKey.val(); let secretKeyInitVal = $secretKey.val(); let enterpriseInitValues = getEnterpriseValues(); + let defaultTheme = {}; + let lastValidConfigParams = {}; + let initialNormalizedConfigParams = null; + let initialCustomThemes = false; + let activeThemeGroup = 'palette'; + let pendingHCaptchaParams = null; + let hCaptchaApiReady = false; modes[ HCaptchaGeneralObject.modeLive ] = HCaptchaGeneralObject.siteKey; modes[ HCaptchaGeneralObject.modeTestPublisher ] = HCaptchaGeneralObject.modeTestPublisherSiteKey; @@ -202,7 +227,35 @@ const general = function( $ ) { showMessage( message, 'notice-error' ); } + function getHCaptchaThemeBackground( theme ) { + if ( ! isObject( theme ) ) { + return ''; + } + + const checkboxFill = getNestedValue( theme, [ 'component', 'checkbox', 'main', 'fill' ] ); + + if ( typeof checkboxFill === 'string' && checkboxFill ) { + return checkboxFill; + } + + const mode = getNestedValue( theme, [ 'palette', 'mode' ] ) || theme.mode || 'light'; + const greyShade = 'dark' === mode ? '800' : '100'; + const paletteFill = getNestedValue( theme, [ 'palette', 'grey', greyShade ] ); + + if ( typeof paletteFill === 'string' && paletteFill ) { + return paletteFill; + } + + return 'dark' === mode ? '#333333' : '#FAFAFA'; + } + function hCaptchaUpdate( params = {} ) { + if ( ! hCaptchaApiReady ) { + pendingHCaptchaParams = Object.assign( {}, pendingHCaptchaParams || {}, params ); + + return; + } + const globalParams = Object.assign( {}, hCaptcha.getParams(), params ); const isCustomThemeActive = $customThemes.prop( 'checked' ); const isModeLive = 'live' === $mode.val(); @@ -222,6 +275,16 @@ const general = function( $ ) { globalParams.theme = hCaptcha.getParams().theme; } + const themeBackground = getHCaptchaThemeBackground( globalParams.theme ); + + if ( $sampleHCaptcha[ 0 ] ) { + if ( themeBackground ) { + $sampleHCaptcha[ 0 ].style.setProperty( '--hcaptcha-theme-editor-background', themeBackground ); + } else { + $sampleHCaptcha[ 0 ].style.removeProperty( '--hcaptcha-theme-editor-background' ); + } + } + hCaptcha.setParams( globalParams ); $sampleHCaptcha.html( '' ); @@ -237,9 +300,11 @@ const general = function( $ ) { hCaptcha.bindEvents(); } - function deepMerge( target, source ) { - const isObject = ( obj ) => obj && typeof obj === 'object'; + function isObject( value ) { + return value !== null && typeof value === 'object' && ! Array.isArray( value ); + } + function deepMerge( target, source ) { if ( ! isObject( target ) || ! isObject( source ) ) { return source; } @@ -260,65 +325,566 @@ const general = function( $ ) { return target; } - function syncConfigParams( configParams, parentKey = '' ) { - for ( const key in configParams ) { - // Construct the full key path. - const fullKey = parentKey ? `${ parentKey }--${ key }` : key; + function deepClone( value ) { + return JSON.parse( JSON.stringify( value ) ); + } - // If the value is an object, recursively print its keys. - if ( typeof configParams[ key ] === 'object' && configParams[ key ] !== null ) { - syncConfigParams( configParams[ key ], fullKey ); - } else { - // Update the custom property selector. - const value = configParams[ key ]; - const propKey = fullKey.replace( /theme--/g, '' ); - const newValue = `${ propKey }=${ value }`; - const $prop = $customProp.find( `option[value*="${ propKey }="]` ); - - if ( $prop.length === 1 ) { - $prop.attr( 'value', newValue ); - if ( $prop.is( ':selected' ) ) { - $customValue.val( value ); - } + function themeValuesMatch( value, defaultValue ) { + if ( typeof value === 'string' && typeof defaultValue === 'string' ) { + return value.toLowerCase() === defaultValue.toLowerCase(); + } + + return value === defaultValue; + } + + function pruneDefaultThemeValues( theme, defaults ) { + Object.keys( theme ).forEach( ( key ) => { + const value = theme[ key ]; + const defaultValue = defaults[ key ]; + + if ( isObject( value ) && isObject( defaultValue ) ) { + pruneDefaultThemeValues( value, defaultValue ); + + if ( 0 === Object.keys( value ).length ) { + delete theme[ key ]; } + + return; + } + + if ( themeValuesMatch( value, defaultValue ) ) { + delete theme[ key ]; } + } ); + } + + function sortConfigValue( value ) { + if ( Array.isArray( value ) ) { + return value.map( sortConfigValue ); + } + + if ( ! isObject( value ) ) { + return value; } + + return Object.keys( value ).sort().reduce( ( result, key ) => { + result[ key ] = sortConfigValue( value[ key ] ); + + return result; + }, {} ); } - function applyCustomThemes( params = {} ) { - let configParamsJson = $configParams.val().trim(); - let configParams; + function normalizeConfigParams( configParams ) { + const normalized = deepClone( configParams ); + + if ( isObject( normalized.theme ) ) { + const defaultMode = getNestedValue( defaultTheme, [ 'palette', 'mode' ] ) || 'light'; + const mode = getNestedValue( normalized.theme, [ 'palette', 'mode' ] ) || defaultMode; + + pruneDefaultThemeValues( normalized.theme, getDefaultThemeForMode( mode ) ); - configParamsJson = configParamsJson ? configParamsJson : null; + if ( mode !== defaultMode ) { + setNestedValue( normalized.theme, [ 'palette', 'mode' ], mode ); + } + + if ( 0 === Object.keys( normalized.theme ).length ) { + delete normalized.theme; + } + } + + return sortConfigValue( normalized ); + } + + function getDefaultThemeForMode( mode ) { + const theme = deepClone( defaultTheme ); + + if ( 'dark' !== mode ) { + return theme; + } + + return deepMerge( theme, { + palette: { + mode: 'dark', + text: { + body: '#F5F5F5', + heading: '#F5F5F5', + }, + }, + component: { + checkbox: { + main: { + border: '#F5F5F5', + fill: '#333333', + }, + hover: { + fill: '#555555', + }, + }, + challenge: { + main: { + border: '#555555', + fill: '#333333', + }, + hover: { + fill: '#555555', + }, + }, + modal: { + main: { + border: '#555555', + fill: '#333333', + }, + hover: { + fill: '#555555', + }, + }, + breadcrumb: { + main: { + fill: '#555555', + }, + }, + button: { + main: { + fill: '#555555', + icon: '#F5F5F5', + text: '#F5F5F5', + }, + hover: { + fill: '#919191', + }, + }, + list: { + main: { + border: '#555555', + fill: '#333333', + }, + }, + listItem: { + main: { + fill: '#333333', + line: '#555555', + text: '#F5F5F5', + }, + hover: { + fill: '#555555', + }, + selected: { + fill: '#555555', + }, + }, + input: { + main: { + border: '#919191', + fill: '#333333', + }, + focus: { + border: '#F5F5F5', + fill: '#555555', + }, + }, + radio: { + main: { + border: '#BFBFBF', + check: '#555555', + file: '#555555', + }, + }, + task: { + main: { + fill: '#555555', + }, + }, + slider: { + main: { + bar: '#919191', + }, + }, + }, + } ); + } + + function parseConfigParams() { + const configParamsJSON = $configParams.val().trim(); + const configParams = configParamsJSON ? JSON.parse( configParamsJSON ) : {}; + + if ( ! isObject( configParams ) ) { + throw new TypeError( HCaptchaGeneralObject.configMustBeObject ); + } + + return configParams; + } + + function getNestedValue( object, path ) { + return path.reduce( ( value, key ) => { + return isObject( value ) && Object.prototype.hasOwnProperty.call( value, key ) + ? value[ key ] + : undefined; + }, object ); + } + + function setNestedValue( object, path, value ) { + let target = object; + + path.forEach( ( key, index ) => { + if ( index === path.length - 1 ) { + target[ key ] = value; + return; + } + + if ( ! isObject( target[ key ] ) ) { + target[ key ] = {}; + } + + target = target[ key ]; + } ); + } + + function humanizeThemeKey( key ) { + const words = String( key ) + .replace( /([a-z0-9])([A-Z])/g, '$1 $2' ) + .replace( /[_-]+/g, ' ' ); + + return words.charAt( 0 ).toUpperCase() + words.slice( 1 ); + } + + function formatThemeFieldLabel( path ) { + const stateLabels = { + active: HCaptchaGeneralObject.activeState, + focus: HCaptchaGeneralObject.focusState, + hover: HCaptchaGeneralObject.hoverState, + main: HCaptchaGeneralObject.mainState, + report: HCaptchaGeneralObject.reportState, + selected: HCaptchaGeneralObject.selectedState, + }; + const parts = path.map( ( key ) => stateLabels[ key ] || humanizeThemeKey( key ) ); + + return parts.join( ' - ' ); + } + + function flattenThemeLeaves( object, path = [], result = [] ) { + Object.keys( object || {} ).forEach( ( key ) => { + const value = object[ key ]; + const valuePath = path.concat( key ); + + if ( isObject( value ) ) { + flattenThemeLeaves( value, valuePath, result ); + } else { + result.push( { path: valuePath, value } ); + } + } ); + + return result; + } + + function getActiveThemeGroupPath() { + return activeThemeGroup.split( '--' ); + } + + function getMergedTheme() { + const customTheme = isObject( lastValidConfigParams.theme ) + ? lastValidConfigParams.theme + : {}; + const mode = getNestedValue( customTheme, [ 'palette', 'mode' ] ) || 'light'; + + return deepMerge( getDefaultThemeForMode( mode ), deepClone( customTheme ) ); + } + + function getThemePreviewValue( theme, path, fallback ) { + const value = getNestedValue( theme, path ); + + return typeof value === 'string' ? value : fallback; + } + + function renderThemePreview() { + const $stage = $themeEditor.find( '[data-theme-editor-preview-stage]' ); + + if ( ! $stage.length ) { + return; + } + + const customTheme = isObject( lastValidConfigParams.theme ) + ? lastValidConfigParams.theme + : {}; + const configuredMode = getNestedValue( customTheme, [ 'palette', 'mode' ] ); + const mode = configuredMode || 'light'; + const logoMode = configuredMode ? mode : 'dark'; + const theme = getMergedTheme(); + const isDark = 'dark' === mode; + const widgetFillFallback = getThemePreviewValue( + customTheme, + [ 'palette', 'grey', isDark ? '800' : '100' ], + isDark ? '#333333' : '#FAFAFA', + ); + const widgetBorderFallback = getThemePreviewValue( + customTheme, + [ 'palette', 'grey', isDark ? '200' : '300' ], + isDark ? '#F5F5F5' : '#E0E0E0', + ); + const previewValues = { + '--hcap-palette-heading': getThemePreviewValue( theme, [ 'palette', 'text', 'heading' ], '#555555' ), + '--hcap-palette-body': getThemePreviewValue( theme, [ 'palette', 'text', 'body' ], '#555555' ), + '--hcap-palette-primary': getThemePreviewValue( theme, [ 'palette', 'primary', 'main' ], '#00838F' ), + '--hcap-checkbox-fill': getThemePreviewValue( customTheme, [ 'component', 'checkbox', 'main', 'fill' ], widgetFillFallback ), + '--hcap-checkbox-border': getThemePreviewValue( customTheme, [ 'component', 'checkbox', 'main', 'border' ], widgetBorderFallback ), + '--hcap-widget-checkbox-fill': getThemePreviewValue( customTheme, [ 'palette', 'grey', '100' ], '#FAFAFA' ), + '--hcap-widget-checkbox-border': getThemePreviewValue( + customTheme, + [ 'palette', 'grey', isDark ? '200' : '700' ], + isDark ? '#F5F5F5' : '#555555', + ), + '--hcap-widget-label': getThemePreviewValue( customTheme, [ 'palette', 'text', 'body' ], '#333333' ), + '--hcap-widget-brand': getThemePreviewValue( + customTheme, + [ 'palette', 'grey', isDark ? '200' : '700' ], + isDark ? '#F5F5F5' : '#555555', + ), + '--hcap-challenge-fill': getThemePreviewValue( theme, [ 'component', 'challenge', 'main', 'fill' ], '#FAFAFA' ), + '--hcap-challenge-border': getThemePreviewValue( theme, [ 'component', 'challenge', 'main', 'border' ], '#E0E0E0' ), + '--hcap-modal-fill': getThemePreviewValue( theme, [ 'component', 'modal', 'main', 'fill' ], '#FFFFFF' ), + '--hcap-modal-border': getThemePreviewValue( theme, [ 'component', 'modal', 'main', 'border' ], '#E0E0E0' ), + '--hcap-breadcrumb-fill': getThemePreviewValue( theme, [ 'component', 'breadcrumb', 'main', 'fill' ], '#F5F5F5' ), + '--hcap-breadcrumb-active': getThemePreviewValue( theme, [ 'component', 'breadcrumb', 'active', 'fill' ], '#00838F' ), + '--hcap-button-fill': getThemePreviewValue( theme, [ 'component', 'button', 'main', 'fill' ], '#FFFFFF' ), + '--hcap-button-icon': getThemePreviewValue( theme, [ 'component', 'button', 'main', 'icon' ], '#555555' ), + '--hcap-list-fill': getThemePreviewValue( theme, [ 'component', 'list', 'main', 'fill' ], '#FFFFFF' ), + '--hcap-list-border': getThemePreviewValue( theme, [ 'component', 'list', 'main', 'border' ], '#D7D7D7' ), + '--hcap-list-item-fill': getThemePreviewValue( theme, [ 'component', 'listItem', 'main', 'fill' ], '#FFFFFF' ), + '--hcap-list-item-line': getThemePreviewValue( theme, [ 'component', 'listItem', 'main', 'line' ], '#F5F5F5' ), + '--hcap-list-item-text': getThemePreviewValue( theme, [ 'component', 'listItem', 'main', 'text' ], '#555555' ), + '--hcap-list-item-selected': getThemePreviewValue( theme, [ 'component', 'listItem', 'selected', 'fill' ], '#E0E0E0' ), + '--hcap-radio-file': getThemePreviewValue( theme, [ 'component', 'radio', 'main', 'file' ], '#F5F5F5' ), + '--hcap-radio-border': getThemePreviewValue( theme, [ 'component', 'radio', 'main', 'border' ], '#919191' ), + '--hcap-radio-check': getThemePreviewValue( theme, [ 'component', 'radio', 'selected', 'check' ], '#00838F' ), + '--hcap-task-fill': getThemePreviewValue( theme, [ 'component', 'task', 'main', 'fill' ], '#F5F5F5' ), + '--hcap-task-selected': getThemePreviewValue( theme, [ 'component', 'task', 'selected', 'border' ], '#00838F' ), + '--hcap-task-report': getThemePreviewValue( theme, [ 'component', 'task', 'report', 'border' ], '#EB5757' ), + '--hcap-prompt-fill': getThemePreviewValue( theme, [ 'component', 'prompt', 'main', 'fill' ], '#00838F' ), + '--hcap-prompt-border': getThemePreviewValue( theme, [ 'component', 'prompt', 'main', 'border' ], '#00838F' ), + '--hcap-prompt-text': getThemePreviewValue( theme, [ 'component', 'prompt', 'main', 'text' ], '#FFFFFF' ), + '--hcap-skip-fill': getThemePreviewValue( theme, [ 'component', 'skipButton', 'main', 'fill' ], '#919191' ), + '--hcap-skip-border': getThemePreviewValue( theme, [ 'component', 'skipButton', 'main', 'border' ], '#919191' ), + '--hcap-skip-text': getThemePreviewValue( theme, [ 'component', 'skipButton', 'main', 'text' ], '#FFFFFF' ), + '--hcap-verify-fill': getThemePreviewValue( theme, [ 'component', 'verifyButton', 'main', 'fill' ], '#00838F' ), + '--hcap-verify-border': getThemePreviewValue( theme, [ 'component', 'verifyButton', 'main', 'border' ], '#00838F' ), + '--hcap-verify-text': getThemePreviewValue( theme, [ 'component', 'verifyButton', 'main', 'text' ], '#FFFFFF' ), + '--hcap-slider-bar': getThemePreviewValue( theme, [ 'component', 'slider', 'main', 'bar' ], '#C4C4C4' ), + '--hcap-slider-handle': getThemePreviewValue( theme, [ 'component', 'slider', 'main', 'handle' ], '#0F8390' ), + }; + + $stage + .attr( 'data-theme-preview-mode', mode ) + .attr( 'data-theme-preview-logo-mode', logoMode ) + .css( previewValues ); + $stage.find( '[data-theme-mock-logo]' ).each( function() { + const $logo = $( this ); + + $logo.prop( 'hidden', logoMode !== $logo.attr( 'data-theme-mock-logo' ) ); + } ); + } + + function setThemePreviewView( view ) { + $themeEditor.find( '[data-theme-preview-view]' ).each( function() { + const $button = $( this ); + const isActive = view === $button.attr( 'data-theme-preview-view' ); + + $button.toggleClass( 'is-active', isActive ); + $button.attr( 'aria-selected', isActive ? 'true' : 'false' ); + } ); + + $themeEditor.find( '[data-theme-preview-pane]' ).each( function() { + const $pane = $( this ); + const isActive = view === $pane.attr( 'data-theme-preview-pane' ); + + $pane.prop( 'hidden', ! isActive ); + } ); + } + + function renderThemeNavigation() { + const $componentNav = $themeEditor.find( '[data-theme-editor-component-nav]' ); + const components = isObject( defaultTheme.component ) ? defaultTheme.component : {}; + + $componentNav.empty(); + + Object.keys( components ).forEach( ( component ) => { + const $button = $( ' + + + +

+ $value ) { - $level[] = $key; - - if ( is_array( $value ) ) { - $result[] = [ implode( '--', $level ) => '' ]; - $result[] = $this->flatten_array( $value ); - - array_pop( $level ); - continue; - } - - $result[] = [ implode( '--', $level ) => $value ]; - - array_pop( $level ); - } - - return array_merge( [], ...$result ); - } } diff --git a/src/php/WP/Login.php b/src/php/WP/Login.php index fa9f9b799..6c5efbc92 100644 --- a/src/php/WP/Login.php +++ b/src/php/WP/Login.php @@ -23,7 +23,17 @@ class Login extends LoginBase { * @return bool */ protected function can_skip_login_verification(): bool { - return ! $this->is_login_limit_exceeded(); + $can_skip = ! $this->is_login_limit_exceeded(); + + /** + * Filters whether native WordPress login verification can be skipped. + * + * Another login integration may take responsibility for verifying the + * same request when it submits to wp-login.php. + * + * @param bool $can_skip Whether native WordPress login verification can be skipped. + */ + return (bool) apply_filters( 'hcap_wp_login_can_skip_verification', $can_skip ); } /** diff --git a/src/php/WP/LoginOut.php b/src/php/WP/LoginOut.php index 7115cfc32..b665eda1a 100644 --- a/src/php/WP/LoginOut.php +++ b/src/php/WP/LoginOut.php @@ -24,7 +24,7 @@ class LoginOut extends LoginBase { protected function init_hooks(): void { parent::init_hooks(); - add_filter( 'login_form_middle', [ $this, 'add_wp_login_out_hcaptcha' ], 10, 2 ); + add_filter( 'login_form_middle', [ $this, 'add_wp_login_out_hcaptcha' ], PHP_INT_MAX - 1, 2 ); } /** @@ -39,7 +39,10 @@ protected function init_hooks(): void { public function add_wp_login_out_hcaptcha( $content, $args ): string { $content = (string) $content; - if ( ! $this->is_wp_login_out_form() ) { + if ( + ! $this->is_wp_login_out_form() || + false !== strpos( $content, 'name="' . HCaptcha::HCAPTCHA_WIDGET_ID . '"' ) + ) { return $content; } diff --git a/src/php/WP/Register.php b/src/php/WP/Register.php index 4e317afed..5ba0e16e6 100644 --- a/src/php/WP/Register.php +++ b/src/php/WP/Register.php @@ -7,6 +7,7 @@ namespace HCaptcha\WP; +use HCaptcha\Abstracts\RegisterBase; use HCaptcha\Helpers\API; use HCaptcha\Helpers\HCaptcha; use WP_Error; @@ -14,7 +15,7 @@ /** * Class Register */ -class Register { +class Register extends RegisterBase { use Base; /** @@ -44,7 +45,9 @@ public function __construct() { * * @return void */ - private function init_hooks(): void { + protected function init_hooks(): void { + parent::init_hooks(); + add_action( 'register_form', [ $this, 'add_captcha' ] ); add_filter( 'registration_errors', [ $this, 'verify' ], 10, 3 ); } @@ -84,6 +87,16 @@ public function verify( $errors, string $sanitized_user_login, string $user_emai return $errors; } + $ownership = $this->get_request_ownership(); + + if ( false === $ownership ) { + return $errors; + } + + if ( null === $ownership ) { + return HCaptcha::add_error_message( $errors, hcap_get_error_messages()['bad-signature'] ); + } + $error_message = API::verify( [ 'nonce_name' => self::NONCE, @@ -100,7 +113,7 @@ public function verify( $errors, string $sanitized_user_login, string $user_emai * * @return array */ - private function get_expected_id(): array { + protected function get_expected_id(): array { return [ 'source' => HCaptcha::get_class_source( __CLASS__ ), 'form_id' => 'register', diff --git a/src/php/Wordfence/General.php b/src/php/Wordfence/General.php index b93ed1b83..9a366763d 100644 --- a/src/php/Wordfence/General.php +++ b/src/php/Wordfence/General.php @@ -15,6 +15,16 @@ */ class General { + /** + * Admin script handle. + */ + private const ADMIN_HANDLE = 'admin-wordfence'; + + /** + * Script localization object. + */ + private const OBJECT = 'HCaptchaWordfenceObject'; + /** * Class constructor. */ @@ -29,11 +39,15 @@ public function __construct() { */ protected function init_hooks(): void { if ( hcaptcha()->settings()->is( 'wordfence_status', 'login' ) ) { - // Disable recaptcha compatibility, otherwise a Wordfence login script fails and cannot show 2FA. + // Disable reCAPTCHA compatibility, otherwise a Wordfence login script fails and cannot show 2FA. hcaptcha()->settings()->set( 'recaptcha_compat_off', [ 'on' ] ); add_action( 'login_enqueue_scripts', [ $this, 'remove_wordfence_recaptcha_script' ], 20 ); add_filter( 'wordfence_ls_require_captcha', [ $this, 'block_wordfence_recaptcha' ] ); + + if ( $this->has_native_hcaptcha_support() ) { + add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] ); + } } else { add_action( 'plugins_loaded', [ $this, 'remove_wp_login_hcaptcha_hooks' ] ); } @@ -42,13 +56,20 @@ protected function init_hooks(): void { } /** - * Remove Wordfence login scripts. + * Remove Wordfence login CAPTCHA scripts. * * @return void */ public function remove_wordfence_recaptcha_script(): void { - wp_dequeue_script( 'wordfence-ls-recaptcha' ); - wp_deregister_script( 'wordfence-ls-recaptcha' ); + $handles = [ + 'wordfence-ls-recaptcha', + 'wordfence-ls-hcaptcha', + ]; + + foreach ( $handles as $handle ) { + wp_dequeue_script( $handle ); + wp_deregister_script( $handle ); + } } /** @@ -61,6 +82,49 @@ public function block_wordfence_recaptcha(): bool { return false; } + /** + * Enqueue the Wordfence admin script. + * + * @param string $hook_suffix The current admin page hook suffix. + * + * @return void + */ + public function admin_enqueue_scripts( string $hook_suffix ): void { + if ( ! $this->has_native_hcaptcha_support() ) { + return; + } + + $wordfence_pages = [ + 'toplevel_page_WFLS', + 'wordfence_page_WFLS', + ]; + + if ( ! in_array( $hook_suffix, $wordfence_pages, true ) ) { + return; + } + + $min = hcap_min_suffix(); + + wp_enqueue_script( + self::ADMIN_HANDLE, + constant( 'HCAPTCHA_URL' ) . "/assets/js/admin-wordfence$min.js", + [ 'jquery' ], + constant( 'HCAPTCHA_VERSION' ), + true + ); + + $notice = HCaptcha::get_hcaptcha_plugin_notice(); + + wp_localize_script( + self::ADMIN_HANDLE, + self::OBJECT, + [ + 'noticeLabel' => $notice['label'], + 'noticeDescription' => $notice['description'], + ] + ); + } + /** * Block hCaptcha on WP login page. * @@ -93,4 +157,16 @@ public function print_inline_styles(): void { HCaptcha::css_display( $css ); } + + /** + * Determine whether Wordfence has native hCaptcha support. + * + * @return bool + */ + private function has_native_hcaptcha_support(): bool { + return defined( 'WordfenceLS\\Controller_CAPTCHA::PROVIDER_HCAPTCHA' ) && + defined( 'WordfenceLS\\Controller_Settings::OPTION_ENABLE_HCAPTCHA' ) && + defined( 'WordfenceLS\\Controller_Settings::OPTION_HCAPTCHA_SITE_KEY' ) && + defined( 'WordfenceLS\\Controller_Settings::OPTION_HCAPTCHA_SECRET' ); + } } diff --git a/src/php/includes/functions.php b/src/php/includes/functions.php index 843988760..91173a8be 100644 --- a/src/php/includes/functions.php +++ b/src/php/includes/functions.php @@ -34,7 +34,8 @@ function hcap_shortcode( $atts ): string { $hcaptcha_theme = $settings->get_theme() ?: 'light'; $hcaptcha_size = $settings->get( 'size' ) ?: 'normal'; - $atts = Utils::unflatten_array( $atts, '--' ); + $atts = Utils::unflatten_array( $atts, '--' ); + $has_honeypot = array_key_exists( 'honeypot', $atts ); /** * Do not set the default size here. @@ -43,19 +44,24 @@ function hcap_shortcode( $atts ): string { */ $atts = shortcode_atts( [ - 'action' => HCAPTCHA_ACTION, - 'name' => HCAPTCHA_NONCE, - 'auto' => false, - 'ajax' => false, - 'force' => $hcaptcha_force, - 'theme' => $hcaptcha_theme, - 'size' => $hcaptcha_size, - 'id' => [], - 'protect' => true, + 'action' => HCAPTCHA_ACTION, + 'name' => HCAPTCHA_NONCE, + 'auto' => false, + 'ajax' => false, + 'force' => $hcaptcha_force, + 'theme' => $hcaptcha_theme, + 'size' => $hcaptcha_size, + 'honeypot' => null, + 'id' => [], + 'protect' => true, ], $atts ); + if ( ! $has_honeypot ) { + unset( $atts['honeypot'] ); + } + /** * Filters the content of the hCaptcha form. * diff --git a/tests/js/assets-js-files/admin-wordfence.test.js b/tests/js/assets-js-files/admin-wordfence.test.js new file mode 100644 index 000000000..4cc812d2e --- /dev/null +++ b/tests/js/assets-js-files/admin-wordfence.test.js @@ -0,0 +1,74 @@ +// noinspection JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +global.HCaptchaWordfenceObject = { + noticeLabel: 'Test Notice Label', + noticeDescription: 'Test Notice Description', +}; + +function getDom() { + // language=HTML + return ` +
+
+
    + +
  • + + +
  • +
+
+
+ `; +} + +describe( 'admin-wordfence', () => { + beforeEach( () => { + document.body.innerHTML = getDom(); + window.hCaptchaWordfence = undefined; + jest.resetModules(); + + require( '../../../assets/js/admin-wordfence.js' ); + } ); + + test( 'replaces native hCaptcha settings with the plugin notice', () => { + window.hCaptchaWordfence.getLocationHref = () => 'https://test.test/wp-admin/admin.php?page=WFLS#top#settings'; + + window.hCaptchaWordfence.ready(); + + const $notice = $( '.hcaptcha-wordfence-notice' ); + + expect( $notice.find( 'strong' ).html() ).toBe( global.HCaptchaWordfenceObject.noticeLabel ); + expect( $notice.find( 'p' ).html() ).toBe( global.HCaptchaWordfenceObject.noticeDescription ); + expect( $( '#wfls-option-enable-hcaptcha' ).length ).toBe( 0 ); + expect( $( '#input-hcaptchaSiteKey' ).length ).toBe( 0 ); + expect( $( '#input-hcaptchaSecret' ).length ).toBe( 0 ); + } ); + + test( 'does not modify another admin page', () => { + window.hCaptchaWordfence.getLocationHref = () => 'https://test.test/wp-admin/admin.php?page=some-page'; + + window.hCaptchaWordfence.ready(); + + expect( $( '#wfls-option-enable-hcaptcha' ).length ).toBe( 1 ); + expect( $( '.hcaptcha-wordfence-notice' ).length ).toBe( 0 ); + } ); + + test( 'does nothing when the Wordfence hCaptcha settings are unavailable', () => { + document.body.innerHTML = ''; + window.hCaptchaWordfence.getLocationHref = () => 'https://test.test/wp-admin/admin.php?page=WFLS'; + + window.hCaptchaWordfence.ready(); + + expect( $( '.hcaptcha-wordfence-notice' ).length ).toBe( 0 ); + } ); + + test( 'getLocationHref returns the current location', () => { + expect( window.hCaptchaWordfence.getLocationHref() ).toBe( 'http://domain.tld/' ); + } ); +} ); diff --git a/tests/js/assets-js-files/general.test.js b/tests/js/assets-js-files/general.test.js index 2a70750bf..82bb7e914 100644 --- a/tests/js/assets-js-files/general.test.js +++ b/tests/js/assets-js-files/general.test.js @@ -22,9 +22,11 @@ const hCaptcha = { }; global.hCaptcha = hCaptcha; +global.hcaptcha = {}; // General object defaults const defaultGeneralObject = { + activeState: 'Active', ajaxUrl: 'https://test.test/wp-admin/admin-ajax.php', badJSONError: 'Bad JSON', checkConfigAction: 'hcap_check_config', @@ -33,6 +35,13 @@ const defaultGeneralObject = { checkingConfigMsg: 'Checking...', completeHCaptchaContent: 'Please solve hCaptcha', completeHCaptchaTitle: 'hCaptcha needed', + configMustBeObject: 'Config Params must be a JSON object.', + focusState: 'Focus', + hexValue: 'hex value', + hoverState: 'Hover', + invalidJSON: 'Invalid JSON', + lastValidPreview: 'Showing the last valid config.', + mainState: 'Main', modeLive: 'live', modeTestEnterpriseBotDetected: 'test_ent_bot', modeTestEnterpriseBotDetectedSiteKey: 'ent-bot-key', @@ -40,7 +49,11 @@ const defaultGeneralObject = { modeTestEnterpriseSafeEndUserSiteKey: 'ent-safe-key', modeTestPublisher: 'test_pub', modeTestPublisherSiteKey: 'pub-key', + reportState: 'Report', + selectedState: 'Selected', siteKey: 'live-key', + unsavedChanges: 'Unsaved changes', + validJSON: 'Valid JSON', OKBtnText: 'OK', CancelBtnText: 'Cancel', }; @@ -60,6 +73,7 @@ let consoleClearSpy; // kaggDialog mock beforeEach( () => { + window.hcaptcha = window.hcaptcha || {}; consoleLogSpy = jest.spyOn( console, 'log' ).mockImplementation( () => {} ); consoleWarnSpy = jest.spyOn( console, 'warn' ).mockImplementation( () => {} ); consoleInfoSpy = jest.spyOn( console, 'info' ).mockImplementation( () => {} ); @@ -87,7 +101,7 @@ function getDom() {
-
+
@@ -111,18 +125,54 @@ function getDom() { - - - +
+ + +
+ -

+

@@ -146,14 +196,31 @@ function getDom() { } // Load modules after DOM is set in each test -function bootGeneral( domOverrides = {} ) { +function bootGeneral( domOverrides = {}, hCaptchaReady = true, initialState = {} ) { jest.resetModules(); document.body.innerHTML = getDom(); + + if ( Object.prototype.hasOwnProperty.call( initialState, 'customThemes' ) ) { + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', initialState.customThemes ); + } + + if ( Object.prototype.hasOwnProperty.call( initialState, 'configParams' ) ) { + $( "textarea[name='hcaptcha_settings[config_params]']" ).val( initialState.configParams ); + } + + if ( Object.prototype.hasOwnProperty.call( initialState, 'previewOnly' ) ) { + $( '.hcaptcha-theme-editor' ).attr( 'data-theme-editor-preview-only', initialState.previewOnly ? 'true' : 'false' ); + } + Object.assign( window.HCaptchaGeneralObject, defaultGeneralObject, domOverrides ); require( '../../../assets/js/settings-base.js' ); require( '../../../assets/js/general.js' ); // Trigger jQuery ready window.hCaptchaGeneral( $ ); + + if ( hCaptchaReady ) { + document.dispatchEvent( new CustomEvent( 'hCaptchaLoaded' ) ); + } } describe( 'general.js basics', () => { @@ -230,12 +297,18 @@ describe( 'general.js basics', () => { test( 'applyCustomThemes: bad JSON disables submit and shows error', () => { bootGeneral(); + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); + const updateCount = hCaptcha.setParams.mock.calls.length; + $cfg.val( '{bad json' ).trigger( 'input' ); const submit = document.getElementById( 'submit' ); - expect( submit.getAttribute( 'disabled' ) ).toBe( 'disabled' ); - expect( $cfg.css( 'background-color' ) ).toBe( 'rgb(255, 171, 175)' ); - expect( document.querySelector( '#hcaptcha-message' ).className ).toContain( 'notice-error' ); + + expect( submit.disabled ).toBe( true ); + expect( $cfg.attr( 'aria-invalid' ) ).toBe( 'true' ); + expect( $( '.hcaptcha-theme-editor-json-status' ).hasClass( 'is-invalid' ) ).toBe( true ); + expect( $( '.hcaptcha-theme-editor-json-error' ).text() ).toContain( 'Bad JSON' ); + expect( hCaptcha.setParams ).toHaveBeenCalledTimes( updateCount ); } ); test( 'applyCustomThemes: not custom themes uses base params and calls setParams', () => { @@ -358,6 +431,26 @@ describe( 'hCaptchaUpdate branches', () => { // 'theme' (object) should NOT be set as a data attribute. expect( $sample.attr( 'data-theme' ) ).not.toBe( '[object Object]' ); } ); + + test( 'updates the server-rendered placeholder with the next checkbox fill', () => { + const $sample = $( '#hcaptcha-options .h-captcha' ); + + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ); + + window.__generalTest.hCaptchaUpdate( { + theme: { + component: { + checkbox: { + main: { + fill: '#123456', + }, + }, + }, + }, + } ); + + expect( $sample[ 0 ].style.getPropertyValue( '--hcaptcha-theme-editor-background' ) ).toBe( '#123456' ); + } ); } ); describe( 'deepMerge', () => { @@ -646,108 +739,287 @@ describe( 'event handlers: secretKey, theme, language, size non-invisible', () = } ); } ); -describe( 'toggleCustomThemeFields and configParams focus', () => { +describe( 'advanced theme editor controls', () => { beforeEach( () => { jest.clearAllMocks(); bootGeneral(); } ); - test( 'customThemes change toggles disabled state of custom fields', () => { + test( 'customThemes change toggles disabled state of editor controls', () => { const $custom = $( "input[name='hcaptcha_settings[custom_themes][]']" ); - const $prop = $( '.hcaptcha-general-custom-prop select' ); - const $val = $( '.hcaptcha-general-custom-value input' ); const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); + const $launcher = $( '.hcaptcha-theme-editor-launcher' ); + const $open = $( '[data-theme-editor-open]' ); // Initially, unchecked — fields should be disabled. - expect( $prop.prop( 'disabled' ) ).toBe( true ); - expect( $val.prop( 'disabled' ) ).toBe( true ); expect( $cfg.prop( 'disabled' ) ).toBe( true ); + expect( $launcher.hasClass( 'is-disabled' ) ).toBe( true ); + expect( $open.prop( 'disabled' ) ).toBe( true ); // Check it. $custom.prop( 'checked', true ).trigger( 'change' ); - expect( $prop.prop( 'disabled' ) ).toBe( false ); - expect( $val.prop( 'disabled' ) ).toBe( false ); expect( $cfg.prop( 'disabled' ) ).toBe( false ); + expect( $launcher.hasClass( 'is-disabled' ) ).toBe( false ); + expect( $open.prop( 'disabled' ) ).toBe( false ); } ); - test( 'configParams focus resets background-color', () => { - const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); - $cfg.css( 'background-color', 'red' ); - $cfg.trigger( 'focus' ); - // jsdom resolves 'unset' to computed value; check the inline style directly. - expect( $cfg[ 0 ].style.backgroundColor ).toBe( 'unset' ); + test( 'initializes editor controls before the hCaptcha API is ready', () => { + const api = window.hcaptcha; + + delete window.hcaptcha; + expect( () => bootGeneral( {}, false ) ).not.toThrow(); + + const $custom = $( "input[name='hcaptcha_settings[custom_themes][]']" ); + + $custom.prop( 'checked', true ).trigger( 'change' ); + expect( $( '[data-theme-editor-open]' ).prop( 'disabled' ) ).toBe( false ); + + window.hcaptcha = api; + hCaptcha.setParams.mockClear(); + document.dispatchEvent( new CustomEvent( 'hCaptchaLoaded' ) ); + expect( hCaptcha.setParams ).toHaveBeenCalled(); } ); -} ); -describe( 'custom prop/value change handlers', () => { - beforeEach( () => { - jest.clearAllMocks(); - bootGeneral(); - // Enable custom themes. + test( 'launcher opens a floating editor, expands Keys, and closes it', () => { + const $custom = $( "input[name='hcaptcha_settings[custom_themes][]']" ); + const $editor = $( '.hcaptcha-theme-editor' ); + const $open = $( '[data-theme-editor-open]' ); + + $custom.prop( 'checked', true ).trigger( 'change' ); + $open.trigger( 'click' ); + + expect( $editor.parent().is( 'form.hcaptcha-general' ) ).toBe( true ); + expect( $editor.find( 'textarea' ).attr( 'name' ) ).toBe( 'hcaptcha_settings[config_params]' ); + expect( $editor.prop( 'hidden' ) ).toBe( false ); + expect( $open.attr( 'aria-expanded' ) ).toBe( 'true' ); + expect( $( '.hcaptcha-section-keys' ).hasClass( 'closed' ) ).toBe( false ); + + $( '[data-theme-editor-close]' ).trigger( 'click' ); + expect( $editor.prop( 'hidden' ) ).toBe( true ); + expect( $open.attr( 'aria-expanded' ) ).toBe( 'false' ); + } ); + + test( 'floating editor can be dragged by its title bar', () => { + const $editor = $( '.hcaptcha-theme-editor' ); + const $handle = $( '[data-theme-editor-drag-handle]' ); + const widthSpy = jest.spyOn( $.fn, 'outerWidth' ).mockReturnValue( 860 ); + const heightSpy = jest.spyOn( $.fn, 'outerHeight' ).mockReturnValue( 600 ); + + $editor[ 0 ].getBoundingClientRect = jest.fn( () => ( { + height: 600, + left: 100, + top: 80, + width: 860, + } ) ); $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + $( '[data-theme-editor-open]' ).trigger( 'click' ); + + $handle.trigger( $.Event( 'pointerdown', { clientX: 120, clientY: 100 } ) ); + $( document ).trigger( $.Event( 'pointermove', { clientX: 220, clientY: 180 } ) ); + $( document ).trigger( 'pointerup' ); + + expect( $editor.css( 'left' ) ).toBe( '156px' ); + expect( $editor.css( 'top' ) ).toBe( '160px' ); + + widthSpy.mockRestore(); + heightSpy.mockRestore(); + } ); + + test( 'tabs switch between visual and JSON panes', () => { + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + $( '[data-theme-editor-tab="json"]' ).trigger( 'click' ); + + expect( $( '[data-theme-editor-pane="visual"]' ).prop( 'hidden' ) ).toBe( true ); + expect( $( '[data-theme-editor-pane="json"]' ).prop( 'hidden' ) ).toBe( false ); + expect( $( '[data-theme-editor-tab="json"]' ).attr( 'aria-selected' ) ).toBe( 'true' ); } ); - test( 'customProp change sets color type and value for non-palette-mode key', () => { - const $prop = $( '.hcaptcha-general-custom-prop select' ); - const $val = $( '.hcaptcha-general-custom-value input' ); + test( 'component navigation renders grouped fields', () => { + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + const $checkboxButton = $( '[data-theme-group="component--checkbox"]' ); - // Select the color option. - $prop.find( 'option' ).eq( 2 ).prop( 'selected', true ); - $prop.trigger( 'change' ); + expect( $checkboxButton.length ).toBe( 1 ); + $checkboxButton.trigger( 'click' ); - expect( $val.attr( 'type' ) ).toBe( 'color' ); + expect( $( '[data-theme-editor-group-title]' ).text() ).toBe( 'Checkbox' ); + expect( $( '[data-theme-editor-fields] .hcaptcha-theme-editor-color-row' ).length ).toBe( 3 ); + expect( $( '[data-theme-preview-pane="widget"]' ).prop( 'hidden' ) ).toBe( false ); } ); - test( 'customProp change sets text type for palette--mode key', () => { - const $prop = $( '.hcaptcha-general-custom-prop select' ); - const $val = $( '.hcaptcha-general-custom-value input' ); + test( 'visual field labels preserve the complete property hierarchy', () => { + const getLabels = () => $( '[data-theme-editor-fields] .hcaptcha-theme-editor-color-label' ) + .map( ( index, element ) => $( element ).text() ) + .get(); + + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + + expect( getLabels() ).toEqual( expect.arrayContaining( [ + 'Grey - 100', + 'Primary - Main', + 'Warn - Main', + 'Text - Heading', + ] ) ); - // Select palette--mode option. - $prop.find( 'option' ).eq( 1 ).prop( 'selected', true ); - $prop.trigger( 'change' ); + $( '[data-theme-group="component--checkbox"]' ).trigger( 'click' ); - expect( $val.attr( 'type' ) ).toBe( 'text' ); - expect( $val.val() ).toBe( 'light' ); + expect( getLabels() ).toEqual( expect.arrayContaining( [ + 'Main - Fill', + 'Main - Border', + 'Hover - Fill', + ] ) ); } ); - test( 'customValue input triggers applyCustomThemes with nested params', () => { - const $prop = $( '.hcaptcha-general-custom-prop select' ); - const $val = $( '.hcaptcha-general-custom-value input' ); + test( 'visual color change updates JSON and live preview', () => { + const $status = $( '.hcaptcha-theme-editor-dirty' ).first(); - // Select palette--mode option. - $prop.find( 'option' ).eq( 1 ).prop( 'selected', true ); - $prop.trigger( 'change' ); + expect( $status.prop( 'hidden' ) ).toBe( true ); + expect( $status.text() ).toBe( '' ); - // Change value. - $val.val( 'dark' ).trigger( 'input' ); + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + hCaptcha.setParams.mockClear(); - // Verify setParams was called (applyCustomThemes calls hCaptchaUpdate, which calls setParams). - expect( hCaptcha.setParams ).toHaveBeenCalled(); + $( '[data-theme-color-path="palette--primary--main"]' ).val( '#123456' ).trigger( 'input' ); + + const config = JSON.parse( $( "textarea[name='hcaptcha_settings[config_params]']" ).val() ); + const previewParams = hCaptcha.setParams.mock.calls.slice( -1 )[ 0 ][ 0 ]; + + expect( config.theme.palette.primary.main ).toBe( '#123456' ); + expect( previewParams.theme.palette.primary.main ).toBe( '#123456' ); + expect( $status.text() ).toBe( 'Unsaved changes' ); + expect( $status.prop( 'hidden' ) ).toBe( false ); + expect( $status.hasClass( 'is-dirty' ) ).toBe( true ); + expect( $( '[data-theme-editor-preview-stage]' ).css( '--hcap-palette-primary' ) ).toBe( '#123456' ); } ); -} ); -describe( 'syncConfigParams recursion and selected prop update', () => { - beforeEach( () => { - jest.clearAllMocks(); - bootGeneral(); + test( 'palette mode change uses sparse dark defaults in both previews', () => { $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); + + $cfg.val( JSON.stringify( { + foo: 1, + theme: { + palette: { + mode: 'light', + grey: { + 100: '#FAFAFA', + }, + }, + component: { + checkbox: { + main: { + border: '#E0E0E0', + fill: '#123456', + }, + }, + }, + }, + } ) ).trigger( 'input' ); + hCaptcha.setParams.mockClear(); + + $( '[data-theme-editor-mode]' ).val( 'dark' ).trigger( 'change' ); + + const config = JSON.parse( $cfg.val() ); + const previewParams = hCaptcha.setParams.mock.calls.slice( -1 )[ 0 ][ 0 ]; + + expect( config.foo ).toBe( 1 ); + expect( config.theme.palette ).toEqual( { mode: 'dark' } ); + expect( config.theme.component.checkbox.main ).toEqual( { fill: '#123456' } ); + expect( previewParams.theme ).toEqual( config.theme ); + expect( $( '[data-theme-editor-preview-stage]' ).attr( 'data-theme-preview-mode' ) ).toBe( 'dark' ); + expect( $( '[data-theme-editor-preview-stage]' ).attr( 'data-theme-preview-logo-mode' ) ).toBe( 'dark' ); + expect( $( '[data-theme-mock-logo="light"]' ).prop( 'hidden' ) ).toBe( true ); + expect( $( '[data-theme-mock-logo="dark"]' ).prop( 'hidden' ) ).toBe( false ); + expect( $( '[data-theme-editor-preview-stage]' ).css( '--hcap-checkbox-fill' ) ).toBe( '#123456' ); + expect( $( '[data-theme-editor-preview-stage]' ).css( '--hcap-widget-checkbox-fill' ) ).toBe( '#FAFAFA' ); + expect( $( '[data-theme-editor-preview-stage]' ).css( '--hcap-widget-checkbox-border' ) ).toBe( '#F5F5F5' ); + expect( $( '[data-theme-editor-preview-stage]' ).css( '--hcap-widget-label' ) ).toBe( '#333333' ); + expect( $( '[data-theme-editor-preview-stage]' ).css( '--hcap-modal-fill' ) ).toBe( '#333333' ); + expect( $( '[data-theme-editor-mode]' ).val() ).toBe( 'dark' ); + } ); + + test( 'legacy config without palette mode uses the same white logo as hCaptcha', () => { + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); + + $cfg.val( '{"theme":{"mode":"light"}}' ).trigger( 'input' ); + + expect( $( '[data-theme-editor-preview-stage]' ).attr( 'data-theme-preview-mode' ) ).toBe( 'light' ); + expect( $( '[data-theme-editor-preview-stage]' ).attr( 'data-theme-preview-logo-mode' ) ).toBe( 'dark' ); + expect( $( '[data-theme-mock-logo="light"]' ).prop( 'hidden' ) ).toBe( true ); + expect( $( '[data-theme-mock-logo="dark"]' ).prop( 'hidden' ) ).toBe( false ); + } ); + + test( 'challenge components select the synthetic challenge preview', () => { + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + $( '[data-theme-group="component--button"]' ).trigger( 'click' ); + + expect( $( '[data-theme-preview-pane="widget"]' ).prop( 'hidden' ) ).toBe( true ); + expect( $( '[data-theme-preview-pane="challenge"]' ).prop( 'hidden' ) ).toBe( false ); + expect( $( '[data-theme-preview-view="challenge"]' ).attr( 'aria-selected' ) ).toBe( 'true' ); } ); - test( 'applyCustomThemes with nested theme object updates option values and selected custom value', () => { + test( 'valid JSON updates visual controls and preserves unknown params', () => { + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); - const $val = $( '.hcaptcha-general-custom-value input' ); - const $prop = $( '.hcaptcha-general-custom-prop select' ); - // Select a palette--mode option (which is selected by default). - $prop.find( 'option' ).eq( 1 ).prop( 'selected', true ); + $cfg.val( '{"foo":1,"theme":{"palette":{"mode":"dark","primary":{"main":"#112233"}}}}' ).trigger( 'input' ); + + expect( $( '[data-theme-editor-mode]' ).val() ).toBe( 'dark' ); + expect( $( '[data-theme-hex-path="palette--primary--main"]' ).val() ).toBe( '#112233' ); + expect( JSON.parse( $cfg.val() ).foo ).toBe( 1 ); + } ); + + test( 'valid JSON restores the preview note line break', () => { + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); + const $note = $( '[data-theme-editor-preview-note]' ); + + expect( $note.find( 'br' ) ).toHaveLength( 1 ); + + $cfg.val( '{bad json' ).trigger( 'input' ); + + expect( $note.text() ).toBe( 'Showing the last valid config.' ); + expect( $note.find( 'br' ) ).toHaveLength( 0 ); - // Set config with a nested theme containing palette.mode. - $cfg.val( '{"theme":{"palette":{"mode":"dark"}}}' ).trigger( 'input' ); + $cfg.val( '{}' ).trigger( 'input' ); - // The selected option value should be updated and $customValue should have the value. - const selectedVal = $prop.find( 'option:selected' ).val(); - expect( selectedVal ).toContain( 'palette--mode=' ); - expect( $val.val() ).toBe( 'dark' ); + expect( $note.find( 'br' ) ).toHaveLength( 1 ); + } ); +} ); + +describe( 'advanced theme editor preview-only mode', () => { + test.each( [ false, true ] )( 'allows previewing without updating or submitting the edited config when Custom Themes is %s', ( customThemes ) => { + const originalConfig = '{"theme":{"palette":{"primary":{"main":"#123456"}}}}'; + + jest.clearAllMocks(); + bootGeneral( {}, true, { + configParams: originalConfig, + customThemes, + previewOnly: true, + } ); + + const $editor = $( '.hcaptcha-theme-editor' ); + const $configParams = $editor.find( 'textarea' ); + const $preservedConfig = $( '[data-theme-editor-original-config]' ); + + expect( $editor.parent().is( 'form.hcaptcha-general' ) ).toBe( true ); + expect( $editor.attr( 'aria-disabled' ) ).toBe( 'false' ); + expect( $( '[data-theme-editor-open]' ).prop( 'disabled' ) ).toBe( false ); + expect( $configParams.prop( 'disabled' ) ).toBe( false ); + expect( $configParams.attr( 'name' ) ).toBeUndefined(); + expect( $preservedConfig.attr( 'name' ) ).toBe( 'hcaptcha_settings[config_params]' ); + expect( $preservedConfig.val() ).toBe( originalConfig ); + + $( '[data-theme-editor-open]' ).trigger( 'click' ); + expect( $editor.prop( 'hidden' ) ).toBe( false ); + + hCaptcha.setParams.mockClear(); + $( '[data-theme-color-path="palette--primary--main"]' ).val( '#654321' ).trigger( 'input' ); + + expect( $( '[data-theme-editor-preview-stage]' ).css( '--hcap-palette-primary' ) ).toBe( '#654321' ); + expect( $preservedConfig.val() ).toBe( originalConfig ); + expect( hCaptcha.setParams ).not.toHaveBeenCalled(); + expect( $( '.hcaptcha-theme-editor-dirty' ).first().prop( 'hidden' ) ).toBe( true ); } ); } ); @@ -785,32 +1057,75 @@ describe( 'remaining branch coverage', () => { expect( $( '#hcaptcha-message' ).hasClass( 'notice-error' ) ).toBe( true ); } ); - test( 'syncConfigParams with non-selected prop does not update customValue', () => { + test( 'reset section removes its overrides and preserves other params', () => { bootGeneral(); $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); - const $prop = $( '.hcaptcha-general-custom-prop select' ); - const $val = $( '.hcaptcha-general-custom-value input' ); + const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); - // Select the color option (index 2), not palette--mode. - $prop.find( 'option' ).eq( 2 ).prop( 'selected', true ); - $prop.find( 'option' ).eq( 1 ).prop( 'selected', false ); + $cfg.val( '{"foo":1,"theme":{"palette":{"mode":"dark"}}}' ).trigger( 'input' ); + $( '[data-theme-editor-reset-section]' ).trigger( 'click' ); - // Set config with palette.mode — the option exists but is NOT selected. - const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); - $cfg.val( '{"theme":{"palette":{"mode":"dark"}}}' ).trigger( 'input' ); + const config = JSON.parse( $cfg.val() ); - // customValue should NOT have been updated to 'dark' since palette--mode is not selected. - expect( $val.val() ).not.toBe( 'dark' ); + expect( config.foo ).toBe( 1 ); + expect( config.theme.palette.mode ).toBe( 'light' ); + expect( config.theme.palette.primary.main ).toBe( '#00838f' ); } ); - test( 'applyCustomThemes with empty configParams uses null', () => { + test.each( [ + [ 'section', '[data-theme-editor-reset-section]' ], + [ 'theme', '[data-theme-editor-reset-theme]' ], + ] )( 'reset %s clears the dirty status after restoring initial defaults', ( type, resetSelector ) => { + bootGeneral( {}, true, { customThemes: true } ); + const $status = $( '.hcaptcha-theme-editor-dirty' ).first(); + + $( '[data-theme-color-path="palette--primary--main"]' ).val( '#123456' ).trigger( 'input' ); + + expect( $status.text() ).toBe( 'Unsaved changes' ); + expect( $status.prop( 'hidden' ) ).toBe( false ); + + $( resetSelector ).trigger( 'click' ); + + expect( $status.text() ).toBe( '' ); + expect( $status.prop( 'hidden' ) ).toBe( true ); + } ); + + test( 'dirty comparison normalizes JSON order, formatting, hex case, and explicit defaults', () => { + bootGeneral( {}, true, { + configParams: '{"z":1,"a":{"second":2,"first":1}}', + customThemes: true, + } ); + const $status = $( '.hcaptcha-theme-editor-dirty' ).first(); + const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); + + $cfg.val( JSON.stringify( { + theme: { + palette: { + primary: { + main: '#00838F', + }, + }, + }, + a: { + first: 1, + second: 2, + }, + z: 1, + }, null, 2 ) ).trigger( 'input' ); + + expect( $status.text() ).toBe( '' ); + expect( $status.prop( 'hidden' ) ).toBe( true ); + } ); + + test( 'applyCustomThemes accepts empty Config Params as an object', () => { bootGeneral(); $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); - // Empty string → configParamsJson becomes null → JSON.parse(null) = null. + $cfg.val( '' ).trigger( 'input' ); - // Should not crash; setParams should still be called. + expect( hCaptcha.setParams ).toHaveBeenCalled(); + expect( hCaptcha.setParams.mock.calls.slice( -1 )[ 0 ][ 0 ].theme ).toEqual( {} ); } ); test( 'credentials changed twice does not re-show notice', () => { diff --git a/tests/js/assets-js-files/hcaptcha-elementor-pro.test.js b/tests/js/assets-js-files/hcaptcha-elementor-pro.test.js index 36d11d96e..fc04535fa 100644 --- a/tests/js/assets-js-files/hcaptcha-elementor-pro.test.js +++ b/tests/js/assets-js-files/hcaptcha-elementor-pro.test.js @@ -25,16 +25,39 @@ describe( 'Elementor Frontend hCaptcha', () => { window.hCaptchaBindEvents = jest.fn(); } ); - test( 'registers wp filter with correct arguments when elementorFrontend is present', () => { + test( 'registers wp filters with correct arguments when elementorFrontend is present', () => { // Call entry function (normally invoked on jQuery ready) window.hCaptchaElementorPro(); - expect( wp.hooks.addFilter ).toHaveBeenCalledTimes( 1 ); + expect( wp.hooks.addFilter ).toHaveBeenCalledTimes( 2 ); expect( wp.hooks.addFilter ).toHaveBeenCalledWith( 'hcaptcha.params', 'hcaptcha', expect.any( Function ), ); + expect( wp.hooks.addFilter ).toHaveBeenCalledWith( + 'hcaptcha.ajaxSubmitButton', + 'hcaptcha', + expect.any( Function ), + ); + } ); + + test( 'marks Elementor form submit buttons as Ajax buttons', () => { + const form = document.createElement( 'form' ); + const submitButton = document.createElement( 'button' ); + const otherButton = document.createElement( 'button' ); + + form.classList.add( 'elementor-form' ); + form.appendChild( submitButton ); + window.hCaptchaElementorPro(); + + const ajaxSubmitButtonFilter = wp.hooks.addFilter.mock.calls.find( + ( call ) => call[ 0 ] === 'hcaptcha.ajaxSubmitButton', + )[ 2 ]; + + expect( ajaxSubmitButtonFilter( false, submitButton ) ).toBe( true ); + expect( ajaxSubmitButtonFilter( false, otherButton ) ).toBe( false ); + expect( ajaxSubmitButtonFilter( true, otherButton ) ).toBe( true ); } ); test( 'triggers hCaptchaBindEvents on ajaxSuccess for Elementor Pro form submission', () => { diff --git a/tests/js/assets-js-files/hcaptcha-login-signup-popup.test.js b/tests/js/assets-js-files/hcaptcha-login-signup-popup.test.js index 18512b244..e7b8c7955 100644 --- a/tests/js/assets-js-files/hcaptcha-login-signup-popup.test.js +++ b/tests/js/assets-js-files/hcaptcha-login-signup-popup.test.js @@ -24,6 +24,12 @@ describe( 'hCaptcha Login Signup Popup', () => { expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'xoo_el_form_action=login' } ] ); - expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + + const formData = new FormData(); + formData.append( 'action', 'xoo_el_form_action' ); + $( document ).trigger( 'ajaxSuccess', [ {}, { data: formData } ] ); + + $( document ).trigger( 'ajaxSuccess', [ {}, { data: { action: 'xoo_el_form_action' } } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 3 ); } ); } ); diff --git a/tests/js/hcaptcha/form-interaction.test.js b/tests/js/hcaptcha/form-interaction.test.js index cee3bf0f2..f9df2a996 100644 --- a/tests/js/hcaptcha/form-interaction.test.js +++ b/tests/js/hcaptcha/form-interaction.test.js @@ -8,6 +8,12 @@ describe( 'FormInteraction', () => { let listener; beforeEach( () => { + global.wp = { + hooks: { + applyFilters: jest.fn( ( hook, selector ) => selector ), + }, + }; + document.body.innerHTML = `
@@ -89,6 +95,10 @@ describe( 'FormInteraction', () => {
+
@@ -102,6 +112,7 @@ describe( 'FormInteraction', () => { afterEach( () => { app.destroy(); document.removeEventListener( eventName, listener ); + delete global.wp; } ); test.each( [ @@ -144,6 +155,21 @@ describe( 'FormInteraction', () => { expect( listener ).toHaveBeenCalledTimes( 1 ); } ); + test( 'uses a filtered form selector for Beaver Builder Login', () => { + wp.hooks.applyFilters.mockReturnValue( 'form, div.fl-login-form' ); + app.init(); + + document.querySelector( '.fl-login-form input' ).dispatchEvent( + new Event( 'pointerdown', { bubbles: true } ), + ); + + expect( wp.hooks.applyFilters ).toHaveBeenCalledWith( + 'hcaptcha.formSelector', + 'form', + ); + expect( listener ).toHaveBeenCalledTimes( 1 ); + } ); + test( 'dispatches the event when Tab moves focus into a protected form', () => { app.init(); diff --git a/tests/manual/hcaptcha-forms-interaction.php b/tests/manual/hcaptcha-forms-interaction.php index afb33e969..7bba884f2 100644 --- a/tests/manual/hcaptcha-forms-interaction.php +++ b/tests/manual/hcaptcha-forms-interaction.php @@ -14,12 +14,77 @@ // - Comment form. // - Post/Page Password form. +// ACF Extended forms handled here: +// - Form containing a reCAPTCHA field replaced by hCaptcha. + +// bbPress forms handled here: +// - Login form. +// - Lost Password form. +// - New Topic form. +// - Registration form. +// - Reply form. + +// Beaver Builder forms handled here: +// - Contact form. +// - Login form. + +// BuddyPress forms handled here: +// - Create Group form. +// - Register form. + +// Blocksy forms handled here: +// - Newsletter Subscribe form. +// - Product Review form. +// - Waitlist form. + +// Jetpack forms handled here: +// - Contact form (classic and block). + // Contact Form 7 forms handled here: // - Contact form. +// CoBlocks forms handled here: +// - Form block. + // Elementor Pro forms handled here: // - Form widget containing an hCaptcha field. +// Fluent Forms forms handled here: +// - Login form. +// - Multi-Step form. +// - Regular form. + +// Formidable Forms forms handled here: +// - Form containing an hCaptcha field. + +// Forminator forms handled here: +// - Multi-Step form. +// - Regular form. + +// Gravity Forms forms handled here: +// - Form using automatic or embedded hCaptcha mode. +// - Gravity Perks Nested Form. + +// Kadence forms handled here: +// - Advanced Form. +// - Form. + +// MetForm forms handled here: +// - Form. + +// Ninja Forms forms handled here: +// - Form containing an hCaptcha field. + +// Otter Blocks forms handled here: +// - Form block. + +// Password Protected forms handled here: +// - Site Password form. + +// Paid Memberships Pro forms handled here: +// - Checkout form. +// - Login form. + // WooCommerce forms handled here: // - Add a Payment Method form. // - Checkout form (classic and block). @@ -37,17 +102,37 @@ // - Email Optin form. // - Login form. +// Download Manager forms handled here: +// - Download button form. + +// Essential Blocks forms handled here: +// - Form block. + // Essential Addons for Elementor forms handled here: // - Login form. // - Registration form. +// GiveWP forms handled here: +// - Donation form (classic and upgraded). + // Mailchimp for WordPress forms handled here: // - Subscription form. +// MailPoet forms handled here: +// - Subscription form. + +// SendInBlue forms handled here: +// - Subscription form. + // Ultimate Addons for Elementor forms handled here: // - Login form. // - Registration form. +// Ultimate Member forms handled here: +// - Login form. +// - Lost Password form. +// - Member Register form. + // Avada forms handled here: // - Avada Form. @@ -57,6 +142,167 @@ // Spectra forms handled here: // - Form block without a reCAPTCHA field. +/** + * Mark the request when ACF Extended renders a reCAPTCHA field replaced by hCaptcha. + * + * @return void + */ +function hcap_forms_mark_acfe_form(): void { + $GLOBALS['hcap_forms_has_acfe_form'] = true; +} + +add_action( 'acf/render_field/type=acfe_recaptcha', 'hcap_forms_mark_acfe_form', 0 ); + +/** + * Mark the request when bbPress renders a shortcode-based protected form. + * + * @param mixed $output Shortcode output. + * @param string $tag Shortcode name. + * + * @return mixed + */ +function hcap_forms_mark_bbpress_shortcode_form( $output, string $tag ) { + if ( is_user_logged_in() || ! in_array( $tag, [ 'bbp-lost-pass', 'bbp-register' ], true ) ) { + return $output; + } + + $GLOBALS['hcap_forms_has_bbpress_form'] = true; + + return $output; +} + +add_filter( 'do_shortcode_tag', 'hcap_forms_mark_bbpress_shortcode_form', 0, 2 ); + +/** + * Mark the request when bbPress renders a login form template. + * + * @param array $templates Possible template files. + * @param string $slug Template slug. + * @param string|null $name Template name. + * + * @return array + */ +function hcap_forms_mark_bbpress_login_template( array $templates, string $slug, ?string $name ): array { + if ( 'form' === $slug && 'user-login' === $name && ! is_user_logged_in() ) { + $GLOBALS['hcap_forms_has_bbpress_form'] = true; + } + + return $templates; +} + +add_filter( 'bbp_get_template_part', 'hcap_forms_mark_bbpress_login_template', 0, 3 ); + +/** + * Mark the request when bbPress renders a login widget. + * + * @param mixed $title Widget title. + * + * @return mixed + */ +function hcap_forms_mark_bbpress_login_widget( $title ) { + if ( ! is_user_logged_in() ) { + $GLOBALS['hcap_forms_has_bbpress_form'] = true; + } + + return $title; +} + +add_filter( 'bbp_login_widget_title', 'hcap_forms_mark_bbpress_login_widget', 0 ); + +/** + * Mark the request when bbPress renders a topic or reply form. + * + * @return void + */ +function hcap_forms_mark_bbpress_editor_form(): void { + $GLOBALS['hcap_forms_has_bbpress_form'] = true; +} + +add_action( 'bbp_theme_after_topic_form_content', 'hcap_forms_mark_bbpress_editor_form', 0 ); +add_action( 'bbp_theme_after_reply_form_content', 'hcap_forms_mark_bbpress_editor_form', 0 ); + +/** + * Mark the request when Beaver Builder renders a protected form module. + * + * @param mixed $output Module HTML output. + * + * @return mixed + */ +function hcap_forms_mark_beaver_builder_form( $output ) { + $output_string = (string) $output; + $contact_form = false !== strpos( $output_string, '
/', $output_string ); + + if ( $contact_form || $login_form ) { + $GLOBALS['hcap_forms_has_beaver_builder_form'] = true; + } + + return $output; +} + +add_filter( 'fl_builder_render_module_content', 'hcap_forms_mark_beaver_builder_form', 0 ); + +/** + * Mark the request when BuddyPress renders a protected form. + * + * @return void + */ +function hcap_forms_mark_buddypress_form(): void { + $GLOBALS['hcap_forms_has_buddypress_form'] = true; +} + +add_action( 'bp_before_registration_submit_buttons', 'hcap_forms_mark_buddypress_form', 0 ); +add_action( 'bp_after_group_details_creation_step', 'hcap_forms_mark_buddypress_form', 0 ); + +/** + * Mark the request when Download Manager renders a protected download form. + * + * @param mixed $template Download template. + * + * @return mixed + */ +function hcap_forms_mark_download_manager_form( $template ) { + if ( preg_match( '/wpdmdl=\d+/', (string) $template ) ) { + $GLOBALS['hcap_forms_has_download_manager_form'] = true; + } + + return $template; +} + +add_filter( 'wpdm_after_fetch_template', 'hcap_forms_mark_download_manager_form', 0 ); + +/** + * Mark the request when Essential Blocks renders a Form block. + * + * @param string|mixed $block_content Block content. + * @param array $block Block data. + * + * @return string + */ +function hcap_forms_mark_essential_blocks_form( $block_content, array $block ): string { + if ( 'essential-blocks/form' === ( $block['blockName'] ?? '' ) ) { + $GLOBALS['hcap_forms_has_essential_blocks_form'] = true; + } + + return (string) $block_content; +} + +add_filter( 'render_block', 'hcap_forms_mark_essential_blocks_form', 0, 2 ); + +/** + * Mark the request when GiveWP renders a protected donation form. + * + * @return void + */ +function hcap_forms_mark_givewp_form(): void { + $GLOBALS['hcap_forms_has_givewp_form'] = true; +} + +add_action( 'give_donation_form_user_info', 'hcap_forms_mark_givewp_form', 0 ); +add_action( 'givewp_donation_form_enqueue_scripts', 'hcap_forms_mark_givewp_form', 0 ); + /** * Mark the request when WordPress renders a comment form. * @@ -83,6 +329,78 @@ function hcap_forms_mark_wp_password_form( $output ): string { add_filter( 'the_password_form', 'hcap_forms_mark_wp_password_form', 0 ); +/** + * Mark the request when WordPress renders a Blocksy newsletter block. + * + * @param string|mixed $block_content Block content. + * @param array $block Block data. + * + * @return string|mixed + */ +function hcap_forms_mark_blocksy_newsletter_form( $block_content, array $block ) { + if ( 'blocksy/newsletter' === ( $block['blockName'] ?? '' ) ) { + $GLOBALS['hcap_forms_has_blocksy_form'] = true; + } + + return $block_content; +} + +add_filter( 'render_block', 'hcap_forms_mark_blocksy_newsletter_form', 0, 2 ); + +/** + * Mark the request when Blocksy renders a product waitlist layer. + * + * @param array|mixed $layer Layer data. + * + * @return void + */ +function hcap_forms_mark_blocksy_waitlist_form( $layer ): void { + if ( 'product_waitlist' === ( $layer['id'] ?? '' ) ) { + $GLOBALS['hcap_forms_has_blocksy_form'] = true; + } +} + +add_action( 'blocksy:woocommerce:product:custom:layer', 'hcap_forms_mark_blocksy_waitlist_form', 0 ); + +/** + * Mark the request when Blocksy renders a product review form. + * + * @param string|mixed $submit_field Submit field markup. + * + * @return string|mixed + */ +function hcap_forms_mark_blocksy_product_review_form( $submit_field ) { + if ( ! function_exists( 'blocksy_manager' ) ) { + return $submit_field; + } + + $manager = blocksy_manager(); + $screen = is_object( $manager ) ? ( $manager->screen ?? null ) : null; + + if ( is_object( $screen ) && method_exists( $screen, 'is_product' ) && $screen->is_product() ) { + $GLOBALS['hcap_forms_has_blocksy_form'] = true; + } + + return $submit_field; +} + +add_filter( 'comment_form_submit_field', 'hcap_forms_mark_blocksy_product_review_form', 0 ); + +/** + * Mark the request when Jetpack renders a contact form. + * + * @param string $html Jetpack contact form HTML. + * + * @return string + */ +function hcap_forms_mark_jetpack_form( string $html ): string { + $GLOBALS['hcap_forms_has_jetpack_form'] = true; + + return $html; +} + +add_filter( 'jetpack_contact_form_html', 'hcap_forms_mark_jetpack_form', 1 ); + /** * Mark the request when Contact Form 7 renders a form. * @@ -98,6 +416,24 @@ function hcap_forms_mark_cf7_form( $form ): string { add_filter( 'wpcf7_form_elements', 'hcap_forms_mark_cf7_form', 0 ); +/** + * Mark the request when WordPress renders a CoBlocks form block. + * + * @param string|mixed $block_content Block content. + * @param array $block Block data. + * + * @return string|mixed + */ +function hcap_forms_mark_coblocks_form( $block_content, array $block ) { + if ( 'coblocks/form' === ( $block['blockName'] ?? '' ) ) { + $GLOBALS['hcap_forms_has_coblocks_form'] = true; + } + + return $block_content; +} + +add_filter( 'render_block', 'hcap_forms_mark_coblocks_form', 0, 2 ); + /** * Mark the request when Elementor Pro renders an hCaptcha form field. * @@ -109,6 +445,226 @@ function hcap_forms_mark_elementor_pro_form(): void { add_action( 'elementor_pro/forms/render_field/hcaptcha', 'hcap_forms_mark_elementor_pro_form', 0 ); +/** + * Mark the request when Fluent Forms renders a form. + * + * @param mixed $form Form data. + * + * @return mixed + */ +function hcap_forms_mark_fluent_form( $form ) { + $GLOBALS['hcap_forms_has_fluent_form'] = true; + + return $form; +} + +add_filter( 'fluentform/rendering_form', 'hcap_forms_mark_fluent_form', 0 ); + +/** + * Mark the request when Fluent Forms renders a conversational form. + * + * Conversational forms load their interactive markup only after hCaptcha loads, + * so they cannot use the built-in form interaction trigger. + * + * @return void + */ +function hcap_forms_mark_fluent_conversational_form(): void { + $GLOBALS['hcap_forms_has_fluent_conversational_form'] = true; +} + +add_action( 'fluentform/conversational_enqueue_assets', 'hcap_forms_mark_fluent_conversational_form', 0 ); + +/** + * Mark the request when Formidable Forms renders an hCaptcha field. + * + * @param string|mixed $html Field HTML. + * @param array $field Field data. + * + * @return string|mixed + */ +function hcap_forms_mark_formidable_form( $html, array $field ) { + if ( 'captcha' !== ( $field['type'] ?? '' ) || false === strpos( (string) $html, 'h-captcha' ) ) { + return $html; + } + + $GLOBALS['hcap_forms_has_formidable_form'] = true; + + return $html; +} + +add_filter( 'frm_replace_shortcodes', 'hcap_forms_mark_formidable_form', 0, 2 ); + +/** + * Mark the request when Forminator renders a custom form. + * + * @param int|mixed $id Form id. + * @param string $form_type Form type. + * + * @return void + */ +function hcap_forms_mark_forminator_form( $id, string $form_type ): void { + if ( 'custom-form' !== $form_type ) { + return; + } + + $GLOBALS['hcap_forms_has_forminator_form'] = true; +} + +add_action( 'forminator_before_form_render', 'hcap_forms_mark_forminator_form', 0, 2 ); + +/** + * Mark the request when Gravity Forms renders a protected form. + * + * @param string|mixed $markup Form opening markup. + * @param array $form Form data and settings. + * + * @return string + */ +function hcap_forms_mark_gravity_form( $markup, array $form ): string { + $settings = function_exists( 'hcaptcha' ) ? hcaptcha()->settings() : null; + + if ( $settings && $settings->is( 'gravity_status', 'form' ) ) { + $GLOBALS['hcap_forms_has_gravity_form'] = true; + + return (string) $markup; + } + + $embed = $settings && $settings->is( 'gravity_status', 'embed' ); + + foreach ( (array) ( $form['fields'] ?? [] ) as $field ) { + $type = is_object( $field ) ? ( $field->type ?? '' ) : ''; + $content = is_object( $field ) ? ( $field->content ?? '' ) : ''; + + if ( ( $embed && 'hcaptcha' === $type ) || has_shortcode( $content, 'hcaptcha' ) ) { + $GLOBALS['hcap_forms_has_gravity_form'] = true; + + break; + } + } + + return (string) $markup; +} + +add_filter( 'gform_form_after_open', 'hcap_forms_mark_gravity_form', 0, 2 ); + +/** + * Mark the request when WordPress renders a protected Kadence form block. + * + * @param string|mixed $block_content Block content. + * @param array $block Block data. + * + * @return string|mixed + */ +function hcap_forms_mark_kadence_form( $block_content, array $block ) { + $block_names = [ + 'kadence/form', + 'kadence/advanced-form-captcha', + 'kadence/advanced-form-submit', + ]; + + if ( in_array( $block['blockName'] ?? '', $block_names, true ) ) { + $GLOBALS['hcap_forms_has_kadence_form'] = true; + } + + return $block_content; +} + +add_filter( 'render_block', 'hcap_forms_mark_kadence_form', 0, 2 ); + +/** + * Mark the request when MetForm renders a submit button widget. + * + * @param mixed $content Widget content. + * @param mixed $widget Elementor widget. + * + * @return mixed + */ +function hcap_forms_mark_metform_form( $content, $widget ) { + if ( ! is_object( $widget ) || ! method_exists( $widget, 'get_name' ) ) { + return $content; + } + + if ( 'mf-button' !== $widget->get_name() ) { + return $content; + } + + $GLOBALS['hcap_forms_has_metform_form'] = true; + + return $content; +} + +add_filter( 'elementor/widget/render_content', 'hcap_forms_mark_metform_form', 0, 2 ); + +/** + * Mark the request when Ninja Forms localizes an hCaptcha field. + * + * @param mixed $field Field data. + * + * @return mixed + */ +function hcap_forms_mark_ninja_form( $field ) { + $GLOBALS['hcap_forms_has_ninja_form'] = true; + + return $field; +} + +add_filter( 'ninja_forms_localize_field_hcaptcha-for-ninja-forms', 'hcap_forms_mark_ninja_form', 0 ); + +/** + * Mark the request when WordPress renders an Otter form block. + * + * @param string|mixed $block_content Block content. + * @param array $block Block data. + * + * @return string|mixed + */ +function hcap_forms_mark_otter_form( $block_content, array $block ) { + if ( 'themeisle-blocks/form' === ( $block['blockName'] ?? '' ) ) { + $GLOBALS['hcap_forms_has_otter_form'] = true; + } + + return $block_content; +} + +add_filter( 'render_block', 'hcap_forms_mark_otter_form', 0, 2 ); + +/** + * Mark the request when Password Protected renders its site password form. + * + * @return void + */ +function hcap_forms_mark_password_protected_form(): void { + $GLOBALS['hcap_forms_has_password_protected_form'] = true; +} + +add_action( 'password_protected_below_password_field', 'hcap_forms_mark_password_protected_form', 0 ); + +/** + * Mark the request when Paid Memberships Pro renders a checkout form. + * + * @return void + */ +function hcap_forms_mark_paid_memberships_pro_checkout_form(): void { + $GLOBALS['hcap_forms_has_paid_memberships_pro_form'] = true; +} + +add_action( 'pmpro_checkout_before_submit_button', 'hcap_forms_mark_paid_memberships_pro_checkout_form', 0 ); + +/** + * Mark the request when Paid Memberships Pro renders a login form. + * + * @param mixed $content Login form HTML output. + * + * @return mixed + */ +function hcap_forms_mark_paid_memberships_pro_login_form( $content ) { + $GLOBALS['hcap_forms_has_paid_memberships_pro_form'] = true; + + return $content; +} + +add_filter( 'pmpro_pages_shortcode_login', 'hcap_forms_mark_paid_memberships_pro_login_form', 0 ); + /** * Mark the request when WooCommerce renders an action-based protected form. * @@ -265,6 +821,43 @@ function hcap_forms_mark_mailchimp_form( $content ) { add_filter( 'mc4wp_form_content', 'hcap_forms_mark_mailchimp_form', 0 ); +/** + * Mark the request when WordPress renders a MailPoet subscription form. + * + * @param string|mixed $content Post content. + * + * @return string + */ +function hcap_forms_mark_mailpoet_form( $content ): string { + $content = (string) $content; + + if ( preg_match( '~~', $content ) ) { + $GLOBALS['hcap_forms_has_mailpoet_form'] = true; + } + + return $content; +} + +add_filter( 'the_content', 'hcap_forms_mark_mailpoet_form', 19 ); + +/** + * Mark the request when SendInBlue renders a subscription form. + * + * @param mixed $output Form HTML output. + * @param string $tag Shortcode name. + * + * @return mixed + */ +function hcap_forms_mark_sendinblue_form( $output, string $tag ) { + if ( 'sibwp_form' === $tag ) { + $GLOBALS['hcap_forms_has_sendinblue_form'] = true; + } + + return $output; +} + +add_filter( 'do_shortcode_tag', 'hcap_forms_mark_sendinblue_form', 0, 2 ); + /** * Mark the request when Ultimate Addons renders a protected form widget. * @@ -286,6 +879,37 @@ function hcap_forms_mark_ultimate_addons_form( $element ): void { add_action( 'elementor/frontend/widget/before_render', 'hcap_forms_mark_ultimate_addons_form', 0 ); +/** + * Mark the request when Ultimate Member renders an hCaptcha form field. + * + * @param mixed $output Field HTML output. + * @param string $mode Form mode. + * + * @return mixed + */ +function hcap_forms_mark_ultimate_member_form( $output, string $mode ) { + if ( ! in_array( $mode, [ 'login', 'password', 'register' ], true ) ) { + return $output; + } + + $GLOBALS['hcap_forms_has_ultimate_member_form'] = true; + + return $output; +} + +add_filter( 'um_hcaptcha_form_edit_field', 'hcap_forms_mark_ultimate_member_form', 0, 2 ); + +/** + * Mark the request when Ultimate Member renders its lost password form. + * + * @return void + */ +function hcap_forms_mark_ultimate_member_lost_password_form(): void { + $GLOBALS['hcap_forms_has_ultimate_member_form'] = true; +} + +add_action( 'um_after_password_reset_fields', 'hcap_forms_mark_ultimate_member_lost_password_form', 0 ); + /** * Mark the request when Avada renders a protected form. * @@ -340,16 +964,42 @@ function hcap_forms_mark_spectra_form( $block_content, array $block ): string { */ function hcap_forms_delay_api_event( $delay_api_event ) { if ( + ! empty( $GLOBALS['hcap_forms_has_acfe_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_bbpress_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_beaver_builder_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_buddypress_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_download_manager_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_essential_blocks_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_givewp_form'] ) || ! empty( $GLOBALS['hcap_forms_has_wp_comment_form'] ) || ! empty( $GLOBALS['hcap_forms_has_wp_password_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_blocksy_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_jetpack_form'] ) || ! empty( $GLOBALS['hcap_forms_has_cf7_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_coblocks_form'] ) || ! empty( $GLOBALS['hcap_forms_has_elementor_pro_form'] ) || + ( + ! empty( $GLOBALS['hcap_forms_has_fluent_form'] ) && + empty( $GLOBALS['hcap_forms_has_fluent_conversational_form'] ) + ) || + ! empty( $GLOBALS['hcap_forms_has_formidable_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_forminator_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_gravity_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_kadence_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_metform_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_ninja_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_otter_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_password_protected_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_paid_memberships_pro_form'] ) || ! empty( $GLOBALS['hcap_forms_has_woocommerce_form'] ) || ! empty( $GLOBALS['hcap_forms_has_wpforms_form'] ) || ! empty( $GLOBALS['hcap_forms_has_divi_form'] ) || ! empty( $GLOBALS['hcap_forms_has_essential_addons_form'] ) || ! empty( $GLOBALS['hcap_forms_has_mailchimp_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_mailpoet_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_sendinblue_form'] ) || ! empty( $GLOBALS['hcap_forms_has_ultimate_addons_form'] ) || + ! empty( $GLOBALS['hcap_forms_has_ultimate_member_form'] ) || ! empty( $GLOBALS['hcap_forms_has_avada_form'] ) || ! empty( $GLOBALS['hcap_forms_has_maintenance_form'] ) || ! empty( $GLOBALS['hcap_forms_has_spectra_form'] ) diff --git a/tests/php/integration/AAAMainTest.php b/tests/php/integration/AAAMainTest.php index a6d9d122f..3362c0bc2 100644 --- a/tests/php/integration/AAAMainTest.php +++ b/tests/php/integration/AAAMainTest.php @@ -38,6 +38,7 @@ use HCaptcha\NF\NF; use HCaptcha\Quform\Quform; use HCaptcha\Sendinblue\Sendinblue; +use HCaptcha\Settings\AntiSpamPage; use HCaptcha\Settings\Settings; use HCaptcha\WC\Checkout; use HCaptcha\WC\OrderTracking; @@ -71,6 +72,43 @@ */ class AAAMainTest extends HCaptchaWPTestCase { + /** + * Test authenticated XML-RPC setting. + * + * @param bool $disabled Whether authenticated XML-RPC is disabled. + * + * @dataProvider dp_test_authenticated_xml_rpc_setting + * @return void + */ + public function test_authenticated_xml_rpc_setting( bool $disabled ): void { + update_option( + AntiSpamPage::OPTION_NAME, + [ AntiSpamPage::DISABLE_XML_RPC_AUTH => $disabled ? [ 'on' ] : [] ] + ); + + FunctionMocker::replace( 'HCaptcha\Helpers\Request::is_xml_rpc', true ); + + $subject = new Main(); + $subject->init_hooks(); + + self::assertSame( ! $disabled, apply_filters( 'xmlrpc_enabled', true ) ); + self::assertFalse( has_action( 'plugins_loaded', [ $subject, 'load_modules' ] ) ); + + remove_filter( 'xmlrpc_enabled', '__return_false', PHP_INT_MAX ); + } + + /** + * Data provider for test_authenticated_xml_rpc_setting(). + * + * @return array[] + */ + public function dp_test_authenticated_xml_rpc_setting(): array { + return [ + 'enabled' => [ true ], + 'disabled' => [ false ], + ]; + } + /** * Included components in test_load_modules(). * @@ -1760,6 +1798,8 @@ public function test_register_recurring_actions(): void { * @throws ReflectionException ReflectionException. */ public function test_load_modules( array $module ): void { + $this->skip_mocked_gravity_forms_module_test( $module ); + [ $option_name, $option_value ] = $module[0]; update_option( @@ -1894,6 +1934,17 @@ static function () use ( $template ) { } } + /** + * Skip the mocked Gravity Forms module test superseded by live integration tests. + * + * @param array $module Module to load. + */ + private function skip_mocked_gravity_forms_module_test( array $module ): void { + if ( 'gravityforms/gravityforms.php' === $module[1] ) { + self::markTestSkipped( 'Covered by the live Gravity Forms integration tests.' ); + } + } + /** * Data provider for test_load_modules(). * diff --git a/tests/php/integration/ACFE/FormTest.php b/tests/php/integration/ACFE/FormTest.php index 37b4ab13c..308957e75 100644 --- a/tests/php/integration/ACFE/FormTest.php +++ b/tests/php/integration/ACFE/FormTest.php @@ -14,18 +14,113 @@ use HCaptcha\ACFE\Form; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use Mockery; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; +use ReflectionClass; use ReflectionException; -use stdClass; -use tad\FunctionMocker\FunctionMocker; /** * Test ACFE class. * * @group acfe */ -class FormTest extends HCaptchaWPTestCase { +class FormTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative paths. + * + * @var string[] + */ + protected static $plugin = [ + 'advanced-custom-fields-pro/acf.php', + 'acf-extended/acf-extended.php', + ]; + + /** + * Hooks to replay after loading ACF and ACF Extended. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; + + /** + * Force lifecycle hook replay after WPTestCase resets action counters. + * + * @var bool + */ + protected static bool $force_plugin_load_hooks = true; + + /** + * Expected deprecation notices from ACF Extended. + * + * @var string[] + */ + protected static array $plugin_expected_deprecated = [ + 'acfe/form/render/before_fields', + ]; + + /** + * Test a live ACF Extended form with its reCAPTCHA field replaced by hCaptcha. + */ + public function test_live_form_render(): void { + $field_group_key = 'group_hcaptcha_acfe_integration'; + + acf_add_local_field_group( + [ + 'key' => $field_group_key, + 'title' => 'hCaptcha integration fields', + 'fields' => [ + [ + 'key' => 'field_hcaptcha_acfe_name', + 'label' => 'Name', + 'name' => 'name', + 'type' => 'text', + ], + [ + 'key' => 'field_hcaptcha_acfe_captcha', + 'label' => 'Captcha', + 'name' => 'captcha', + 'type' => 'acfe_recaptcha', + 'required' => 1, + 'version' => 'v2', + 'v2_theme' => 'light', + 'v2_size' => 'normal', + 'site_key' => 'live-site-key', + 'secret_key' => 'live-secret-key', + ], + ], + 'location' => [], + ] + ); + + acfe_register_form( + [ + 'name' => 'hcaptcha-acfe-integration', + 'title' => 'hCaptcha integration form', + 'field_groups' => [ $field_group_key ], + ] + ); + + $integration = new Form(); + $field_type = acf_get_field_type( 'acfe_recaptcha' ); + $plugin_file = wp_normalize_path( ( new ReflectionClass( $field_type ) )->getFileName() ); + + ob_start(); + acfe_form( 'hcaptcha-acfe-integration' ); + $html = ob_get_clean(); + + self::assertTrue( is_plugin_active( 'advanced-custom-fields-pro/acf.php' ) ); + self::assertTrue( is_plugin_active( 'acf-extended/acf-extended.php' ) ); + self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/acf-extended/' ), $plugin_file ); + self::assertInstanceOf( \acfe_field_recaptcha::class, $field_type ); + self::assertSame( 11, has_action( Form::RENDER_HOOK, [ $integration, 'add_hcaptcha' ] ) ); + self::assertStringContainsString( 'class="acfe-form"', $html ); + self::assertStringContainsString( 'name="acf[field_hcaptcha_acfe_name]"', $html ); + self::assertStringContainsString( 'class="h-captcha"', $html ); + self::assertStringContainsString( 'id="acf-field_hcaptcha_acfe_captcha"', $html ); + } /** * Tear down the test. @@ -80,10 +175,7 @@ public function test_before_fields(): void { * @dataProvider dp_test_remove_recaptcha_render */ public function test_remove_recaptcha_render( array $field, $expected ): void { - $recaptcha = Mockery::mock( 'acfe_field_recaptcha' ); - $recaptcha->shouldReceive( 'render_field' ); - - FunctionMocker::replace( 'acf_get_field_type', $recaptcha ); + $recaptcha = acf_get_field_type( 'acfe_recaptcha' ); add_action( Form::RENDER_HOOK, [ $recaptcha, 'render_field' ], 9 ); @@ -177,10 +269,7 @@ public function test_remove_recaptcha_verify(): void { $field = [ 'type' => 'some' ]; $input = 'some_input_name'; - $recaptcha = Mockery::mock( 'acfe_field_recaptcha' ); - $recaptcha->shouldReceive( 'render_field' ); - - FunctionMocker::replace( 'acf_get_field_type', $recaptcha ); + $recaptcha = acf_get_field_type( 'acfe_recaptcha' ); add_filter( Form::VALIDATION_HOOK, [ $recaptcha, 'validate_value' ] ); @@ -214,7 +303,7 @@ public function test_verify( bool $result, $expected ): void { $this->prepare_widget_id( $form_id ); $_POST['acf'] = []; - $this->mock_acf_form( $form_id ); + $this->register_acf_form( $form_id ); $this->prepare_verify_request( $value, $result ); @@ -280,7 +369,7 @@ public function test_verify_direct_post_without_ajax( bool $result, $expected ): $this->prepare_widget_id( $form_id ); $_POST['acf'] = []; - $this->mock_acf_form( $form_id ); + $this->register_acf_form( $form_id ); $this->prepare_verify_request( $value, $result ); $subject = new Form(); @@ -305,7 +394,7 @@ public function test_verify_ajax(): void { $_POST['_acf_form'] = 'acf-form'; $this->prepare_widget_id( $form_id ); - $this->mock_acf_form( $form_id ); + $this->register_acf_form( $form_id ); $this->prepare_verify_request( $value, false ); add_filter( 'wp_doing_ajax', '__return_true' ); @@ -334,7 +423,7 @@ public function test_verify_ajax_missing_widget_id(): void { $_POST['_acf_form'] = 'acf-form'; - $this->mock_acf_form( $form_id ); + $this->register_acf_form( $form_id ); $this->prepare_verify_request( $value ); add_filter( 'wp_doing_ajax', '__return_true' ); @@ -362,24 +451,21 @@ private function prepare_widget_id( int $form_id ): void { } /** - * Mock an ACF form for verification. + * Register an ACF form for verification. * * @param array|false|int $form Form data. * * @return void */ - private function mock_acf_form( $form ): void { - $return = is_int( $form ) ? [ 'ID' => $form ] : $form; - $acf = new stdClass(); - $acf->form_front = Mockery::mock(); - $acf->form_front->shouldReceive( 'get_form' )->andReturn( $return ); - - FunctionMocker::replace( - 'acf', - static function () use ( $acf ) { - return $acf; - } - ); + private function register_acf_form( $form ): void { + if ( false === $form ) { + return; + } + + $form = is_int( $form ) ? [ 'ID' => $form ] : $form; + $form = array_merge( [ 'id' => 'acf-form' ], $form ); + + acf()->form_front->add_form( $form ); } /** @@ -438,18 +524,21 @@ public function test_get_entry_and_data(): void { 'type' => 'text', 'name' => 'empty', ], - '' => [ - 'label' => '', - 'type' => 'text', - 'name' => '', - ], ]; - FunctionMocker::replace( - 'acf_get_field', - static function ( $field_key ) use ( $acf_fields ) { - return $acf_fields[ $field_key ] ?? null; - } + acf_add_local_field_group( + [ + 'key' => 'group_hcaptcha_acfe_entry', + 'title' => 'ACFE entry fields', + 'fields' => array_map( + static function ( $field_key, $field ) { + return array_merge( [ 'key' => $field_key ], $field ); + }, + array_keys( $acf_fields ), + $acf_fields + ), + 'location' => [], + ] ); $subject = new Form(); @@ -490,17 +579,10 @@ public function test_get_form(): void { self::assertFalse( $get_form->invoke( $subject ) ); $_POST['_acf_form'] = 'acf-form'; - $this->mock_acf_form( [ 'ID' => 55 ] ); - self::assertSame( [ 'ID' => 55 ], $get_form->invoke( $subject ) ); - - $_POST['_acf_form'] = 'encrypted-form'; - $this->mock_acf_form( false ); - FunctionMocker::replace( - 'acf_decrypt', - static function () { - return wp_json_encode( [ 'ID' => 77 ] ); - } - ); + $this->register_acf_form( [ 'ID' => 55 ] ); + self::assertSame( 55, $get_form->invoke( $subject )['ID'] ); + + $_POST['_acf_form'] = acf_encrypt( wp_json_encode( [ 'ID' => 77 ] ) ); self::assertSame( [ 'ID' => 77 ], $get_form->invoke( $subject ) ); } diff --git a/tests/php/integration/Admin/WhatsNewTest.php b/tests/php/integration/Admin/WhatsNewTest.php index 8e0d572ca..9c9b7af3a 100644 --- a/tests/php/integration/Admin/WhatsNewTest.php +++ b/tests/php/integration/Admin/WhatsNewTest.php @@ -422,6 +422,7 @@ public function test_render_popup(): void { self::assertStringContainsString( '' . $version . '', $html ); self::assertStringContainsString( 'class="hcaptcha-whats-new-version-toggle"', $html ); self::assertStringContainsString( 'id="hcaptcha-whats-new-versions"', $html ); + self::assertStringContainsString( 'data-version="5.3.0"', $html ); self::assertStringContainsString( 'data-version="5.2.0"', $html ); self::assertStringContainsString( 'data-version="5.1.0"', $html ); self::assertStringContainsString( 'data-version="5.0.0"', $html ); @@ -951,6 +952,47 @@ static function ( $args ) { self::assertStringContainsString( 'assets/images/magnifying-glass.svg', $html ); } + /** + * Test whats_new_5_3_0(). + * + * @return void + */ + public function test_whats_new_5_3_0(): void { + add_filter( + 'hcap_settings_init_args', + static function ( $args ) { + $args['mode'] = 'tabs'; + + return $args; + } + ); + + unset( $current_user ); + wp_set_current_user( 1 ); + hcaptcha()->init_hooks(); + set_current_screen( 'hcaptcha' ); + do_action( 'admin_menu' ); + + $subject = Mockery::mock( WhatsNew::class )->makePartial(); + + $subject->shouldAllowMockingProtectedMethods(); + + ob_start(); + + $subject->whats_new_5_3_0(); + + $html = ob_get_clean(); + + self::assertStringContainsString( 'Advanced Theme Editor', $html ); + self::assertStringContainsString( 'Create a custom hCaptcha theme visually', $html ); + self::assertStringContainsString( 'widget and challenge', $html ); + self::assertStringContainsString( 'JSON view', $html ); + self::assertStringContainsString( 'Open Theme Editor', $html ); + self::assertStringContainsString( '#config_params', $html ); + self::assertStringContainsString( 'assets/images/advanced-theme-editor.jpg', $html ); + self::assertStringContainsString( 'class="hcaptcha-lightbox"', $html ); + } + /** * Test update_whats_new(). * diff --git a/tests/php/integration/Affiliates/RegisterTest.php b/tests/php/integration/Affiliates/RegisterTest.php new file mode 100644 index 000000000..fe4d29872 --- /dev/null +++ b/tests/php/integration/Affiliates/RegisterTest.php @@ -0,0 +1,39 @@ +before_section( 'registration' ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo ''; + $subject->after_section( 'registration' ); + $output = (string) ob_get_clean(); + + self::assertStringContainsString( 'name="hcaptcha-widget-id"', $output ); + self::assertStringNotContainsString( '
', $output ); + } +} diff --git a/tests/php/integration/AutoVerify/AutoVerifyTest.php b/tests/php/integration/AutoVerify/AutoVerifyTest.php index 04ba66f42..a59c1de61 100644 --- a/tests/php/integration/AutoVerify/AutoVerifyTest.php +++ b/tests/php/integration/AutoVerify/AutoVerifyTest.php @@ -410,6 +410,28 @@ static function ( $result, $deprecated, $info ) use ( &$error_info ) { self::assertSame( [], $error_info->expected_id ); } + /** + * Test verify_form() defers an unmatched request to a dedicated verifier. + * + * @return void + */ + public function test_verify_form_defers_unmatched_request(): void { + $request_uri = $this->get_test_request_uri(); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = $request_uri; + $_POST['test_input'] = 'some input'; + + set_transient( AutoVerify::TRANSIENT, $this->get_test_registered_forms() ); + add_filter( 'hcap_auto_verify_unmatched_form', '__return_null' ); + + $subject = new AutoVerify(); + $subject->verify(); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing + self::assertSame( [ 'test_input' => 'some input' ], $_POST ); + } + /** * Test verify_form() when verify is successful. */ diff --git a/tests/php/integration/Avada/FormTest.php b/tests/php/integration/Avada/FormTest.php index 6e5bba41f..a567368ea 100644 --- a/tests/php/integration/Avada/FormTest.php +++ b/tests/php/integration/Avada/FormTest.php @@ -14,14 +14,47 @@ use HCaptcha\Avada\Form; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; /** * Test FormTest class. * * @group avada */ -class FormTest extends HCaptchaWPTestCase { +class FormTest extends HCaptchaPluginWPTestCase { + + /** + * Avada Builder plugin entry file. + * + * @var string + */ + protected static $plugin = 'fusion-builder/fusion-builder.php'; + + /** + * Theme stylesheet slug. + * + * @var string + */ + protected static string $theme = 'Avada'; + + /** + * Hooks to replay after loading Avada Builder. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'after_setup_theme', + 'init', + 'fusion_builder_before_init', + 'wp_loaded', + ]; + + /** + * Expected incorrect usage notices caused by the late theme load. + * + * @var string[] + */ + protected static array $theme_expected_incorrect_usage = [ "add_theme_support( 'title-tag' )" ]; /** * Tear down the test. @@ -45,12 +78,44 @@ public function tearDown(): void { public function test_init_hooks(): void { $subject = new Form(); + self::assertSame( 'Avada', get_template() ); + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertTrue( class_exists( 'Fusion_Form_Builder' ) ); + self::assertTrue( shortcode_exists( 'fusion_form' ) ); self::assertSame( 10, has_action( 'fusion_form_after_open', [ $subject, 'form_after_open' ] ) ); self::assertSame( 10, has_filter( 'fusion_builder_form_submission_data', [ $subject, 'submission_data' ] ) ); self::assertSame( 10, has_action( 'fusion_element_form_content', [ $subject, 'add_hcaptcha' ] ) ); self::assertSame( 10, has_filter( 'fusion_form_demo_mode', [ $subject, 'verify' ] ) ); } + /** + * Test the live Avada Form element. + * + * @return void + */ + public function test_live_avada_form(): void { + $form_id = $this->factory()->post->create( + [ + 'post_title' => 'Avada Test Form', + 'post_name' => 'avada-test-form', + 'post_content' => '[fusion_builder_container type="flex"][fusion_builder_row][fusion_builder_column type="1_1" layout="1_1"][fusion_form_text label="Text" name="text" required="yes" /][fusion_form_email label="Email" name="email" /][fusion_form_textarea label="Message" name="textarea" /][fusion_form_submit]Submit[/fusion_form_submit][/fusion_builder_column][/fusion_builder_row][/fusion_builder_container]', + 'post_status' => 'publish', + 'post_type' => 'fusion_form', + ] + ); + + update_option( 'hcaptcha_settings', [ 'avada_status' => [ 'form' ] ] ); + hcaptcha()->init_hooks(); + new Form(); + + $output = do_shortcode( '[fusion_form form_post_id="' . $form_id . '" /]' ); + + self::assertStringContainsString( 'class="fusion-form', $output ); + self::assertStringContainsString( '
'; $comment_args = []; @@ -75,7 +107,8 @@ static function () use ( $manager ) { $result = $subject->add_hcaptcha( $submit_field, $comment_args ); - // Should contain signature but not hcaptcha form. + self::assertFalse( blocksy_manager()->screen->is_product() ); + self::assertStringNotContainsString( 'Submit', $result ); } @@ -98,19 +131,6 @@ public function test_add_hcaptcha_product(): void { $GLOBALS['wp_query']->queried_object = get_post( $product_id ); $GLOBALS['wp_query']->queried_object_id = $product_id; - $screen = Mockery::mock( 'Blocksy_Screen_Manager' ); - $screen->shouldReceive( 'is_product' )->andReturn( true ); - - $manager = new stdClass(); - $manager->screen = $screen; - - FunctionMocker::replace( - 'blocksy_manager', - static function () use ( $manager ) { - return $manager; - } - ); - $submit_field = '
'; $comment_args = []; @@ -118,37 +138,11 @@ static function () use ( $manager ) { $result = $subject->add_hcaptcha( $submit_field, $comment_args ); + self::assertTrue( blocksy_manager()->screen->is_product() ); self::assertStringContainsString( 'h-captcha', $result ); self::assertStringContainsString( '', $result ); } - /** - * Test add_hcaptcha() when the screen is null. - * - * @return void - */ - public function test_add_hcaptcha_no_screen(): void { - $manager = new stdClass(); - $manager->screen = null; - - FunctionMocker::replace( - 'blocksy_manager', - static function () use ( $manager ) { - return $manager; - } - ); - - $submit_field = '
'; - $comment_args = []; - - $subject = new ProductReview(); - - $result = $subject->add_hcaptcha( $submit_field, $comment_args ); - - // Should contain a signature but not hcaptcha form (not a product). - self::assertStringContainsString( '', $result ); - } - /** * Test print_inline_styles(). * @@ -156,42 +150,16 @@ static function () use ( $manager ) { * @noinspection CssUnusedSymbol */ public function test_print_inline_styles(): void { - FunctionMocker::replace( - 'defined', - static function ( $constant_name ) { - return 'SCRIPT_DEBUG' === $constant_name; - } - ); - - FunctionMocker::replace( - 'constant', - static function ( $name ) { - return 'SCRIPT_DEBUG' === $name; - } - ); - - $expected = <<<'CSS' - .ct-product-waitlist-form input[type="email"] { - grid-row: 1; - } - - .ct-product-waitlist-form h-captcha { - grid-row: 2; - margin-bottom: 0; - } - - .ct-product-waitlist-form button { - grid-row: 3; - } -CSS; - $expected = "\n"; - $subject = new ProductReview(); ob_start(); $subject->print_inline_styles(); - self::assertSame( $expected, ob_get_clean() ); + $output = ob_get_clean(); + + self::assertStringContainsString( '.ct-product-waitlist-form input[type="email"]', $output ); + self::assertStringContainsString( '.ct-product-waitlist-form h-captcha', $output ); + self::assertStringContainsString( '.ct-product-waitlist-form button', $output ); } } diff --git a/tests/php/integration/Blocksy/WaitlistTest.php b/tests/php/integration/Blocksy/WaitlistTest.php index 148555824..636f5409a 100644 --- a/tests/php/integration/Blocksy/WaitlistTest.php +++ b/tests/php/integration/Blocksy/WaitlistTest.php @@ -9,9 +9,9 @@ use HCaptcha\Blocksy\Waitlist; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; +use ReflectionClass; use ReflectionException; -use tad\FunctionMocker\FunctionMocker; use WP_Error; /** @@ -19,7 +19,50 @@ * * @group blocksy */ -class WaitlistTest extends HCaptchaWPTestCase { +class WaitlistTest extends HCaptchaPluginWPTestCase { + + /** + * WooCommerce and Blocksy Companion entry files. + * + * @var string[] + */ + protected static $plugin = [ + 'woocommerce/woocommerce.php', + 'blocksy-companion/blocksy-companion.php', + ]; + + /** + * Blocksy theme stylesheet. + * + * @var string + */ + protected static string $theme = 'blocksy'; + + /** + * Hooks to replay after loading the plugins. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; + + /** + * Test that the live Blocksy stack is loaded for the waitlist integration. + * + * @return void + */ + public function test_live_blocksy_stack_is_loaded(): void { + $theme_file = wp_normalize_path( ( new ReflectionClass( 'Blocksy_Screen_Manager' ) )->getFileName() ); + $plugin_file = wp_normalize_path( ( new ReflectionClass( \Blocksy\Plugin::class ) )->getFileName() ); + + self::assertTrue( is_plugin_active( 'woocommerce/woocommerce.php' ) ); + self::assertTrue( is_plugin_active( 'blocksy-companion/blocksy-companion.php' ) ); + self::assertSame( 'blocksy', get_stylesheet() ); + self::assertStringStartsWith( wp_normalize_path( get_theme_root() . '/blocksy/' ), $theme_file ); + self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/blocksy-companion/' ), $plugin_file ); + } /** * Tear down the test. @@ -341,42 +384,16 @@ public function test_enqueue_scripts(): void { * @noinspection CssUnusedSymbol */ public function test_print_inline_styles(): void { - FunctionMocker::replace( - 'defined', - static function ( $constant_name ) { - return 'SCRIPT_DEBUG' === $constant_name; - } - ); - - FunctionMocker::replace( - 'constant', - static function ( $name ) { - return 'SCRIPT_DEBUG' === $name; - } - ); - - $expected = <<<'CSS' - .ct-product-waitlist-form input[type="email"] { - grid-row: 1; - } - - .ct-product-waitlist-form h-captcha { - grid-row: 2; - margin-bottom: 0; - } - - .ct-product-waitlist-form button { - grid-row: 3; - } -CSS; - $expected = "\n"; - $subject = new Waitlist(); ob_start(); $subject->print_inline_styles(); - self::assertSame( $expected, ob_get_clean() ); + $output = ob_get_clean(); + + self::assertStringContainsString( '.ct-product-waitlist-form input[type="email"]', $output ); + self::assertStringContainsString( '.ct-product-waitlist-form h-captcha', $output ); + self::assertStringContainsString( '.ct-product-waitlist-form button', $output ); } } diff --git a/tests/php/integration/BuddyPress/CreateGroupTest.php b/tests/php/integration/BuddyPress/CreateGroupTest.php index 20bf87c78..5aa0bc98a 100644 --- a/tests/php/integration/BuddyPress/CreateGroupTest.php +++ b/tests/php/integration/BuddyPress/CreateGroupTest.php @@ -17,7 +17,6 @@ use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use ReflectionException; -use tad\FunctionMocker\FunctionMocker; /** * Test CreateGroup. @@ -33,13 +32,48 @@ class CreateGroupTest extends HCaptchaPluginWPTestCase { */ protected static $plugin = 'buddypress/bp-loader.php'; + /** + * Hooks to replay after loading BuddyPress. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'setup_theme', + 'after_setup_theme', + 'init', + ]; + + /** + * Initialize BuddyPress after the WordPress test bootstrap. + * + * @var bool + */ + protected static bool $force_plugin_load_hooks = true; + + /** + * Enable the BuddyPress Groups component used by these tests. + * + * @return void + */ + protected function before_load_test_plugins(): void { + add_filter( + 'bp_active_components', + static function ( $components ) { + $components['groups'] = '1'; + + return $components; + } + ); + } + /** * Tear down the test. */ public function tearDown(): void { global $bp; - unset( $bp->signup ); + unset( $bp->signup, $bp->current_component, $bp->current_action, $bp->action_variables ); parent::tearDown(); } @@ -70,16 +104,37 @@ public function test_hcap_bp_group_form(): void { self::assertSame( $expected, ob_get_clean() ); } + /** + * Test hCaptcha in the live BuddyPress group creation template. + * + * @return void + */ + public function test_live_group_creation_template(): void { + $bp = buddypress(); + $bp->current_component = 'groups'; + $bp->current_action = 'create'; + $bp->action_variables = [ 1 => 'group-details' ]; + $subject = new CreateGroup(); + $template = bp_locate_template( 'groups/create.php' ); + $html = bp_buffer_template_part( 'groups/create', null, false ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( + wp_normalize_path( WP_PLUGIN_DIR . '/buddypress/' ), + wp_normalize_path( (string) $template ) + ); + self::assertStringContainsString( 'id="create-group-form"', $html ); + self::assertStringContainsString( 'name="group-name"', $html ); + self::assertStringContainsString( 'class="hcap_buddypress_group_form"', $html ); + self::assertStringContainsString( 'name="hcaptcha_bp_create_group_nonce"', $html ); + self::assertSame( 10, has_action( 'bp_after_group_details_creation_step', [ $subject, 'add_captcha' ] ) ); + } + /** * Test verify(). */ public function test_verify(): void { - FunctionMocker::replace( - 'bp_is_group_creation_step', - static function ( $step_slug ) { - return 'group-details' === $step_slug; - } - ); + $this->set_group_creation_step(); $subject = new CreateGroup(); @@ -132,7 +187,7 @@ public function test_get_entry(): void { * Test verify() when not in step. */ public function test_verify_not_in_step(): void { - FunctionMocker::replace( 'bp_is_group_creation_step', false ); + $this->set_group_creation_step( false ); $subject = new CreateGroup(); @@ -146,17 +201,11 @@ public function test_verify_not_in_step(): void { * @noinspection PhpUndefinedFunctionInspection */ public function test_verify_not_verified(): void { - FunctionMocker::replace( - 'bp_is_group_creation_step', - static function ( $step_slug ) { - return 'group-details' === $step_slug; - } - ); + $this->set_group_creation_step(); - FunctionMocker::replace( - 'HCaptcha\\BuddyPress\\bp_core_redirect', - static function (): void {} - ); + if ( ! defined( 'BP_TESTS_DIR' ) ) { + define( 'BP_TESTS_DIR', __DIR__ ); + } add_filter( 'wp_redirect', @@ -167,8 +216,6 @@ static function ( $location, $status ) { 2 ); - FunctionMocker::replace( 'bp_get_groups_root_slug', '' ); - $subject = new CreateGroup(); $this->prepare_verify_post( 'hcaptcha_bp_create_group_nonce', 'hcaptcha_bp_create_group', null ); @@ -189,34 +236,16 @@ static function ( $location, $status ) { * @noinspection CssUnusedSymbol */ public function test_print_inline_styles(): void { - FunctionMocker::replace( - 'defined', - static function ( $constant_name ) { - return 'SCRIPT_DEBUG' === $constant_name; - } - ); - - FunctionMocker::replace( - 'constant', - static function ( $name ) { - return 'SCRIPT_DEBUG' === $name; - } - ); - - $expected = <<<'CSS' - #buddypress .h-captcha { - margin-top: 15px; - } -CSS; - $expected = "\n"; - $subject = new CreateGroup(); ob_start(); $subject->print_inline_styles(); + $css = (string) ob_get_clean(); - self::assertSame( $expected, ob_get_clean() ); + self::assertStringContainsString( '\n"; - $subject = new Form(); // Show styles. @@ -412,6 +425,9 @@ static function ( $name ) { $subject->print_inline_styles(); - self::assertSame( $expected, ob_get_clean() ); + $output = ob_get_clean(); + + self::assertStringContainsString( '.wp-block-coblocks-form .h-captcha-error', $output ); + self::assertStringContainsString( '.wp-block-coblocks-form .h-captcha', $output ); } } diff --git a/tests/php/integration/Divi/CommentTest.php b/tests/php/integration/Divi/CommentTest.php index f1f416cb4..99ff74cab 100644 --- a/tests/php/integration/Divi/CommentTest.php +++ b/tests/php/integration/Divi/CommentTest.php @@ -8,91 +8,123 @@ namespace HCaptcha\Tests\Integration\Divi; use HCaptcha\Divi\Comment; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use tad\FunctionMocker\FunctionMocker; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; /** - * Class CommentTest + * Class CommentTest. * * @group divi */ -class CommentTest extends HCaptchaWPTestCase { +class CommentTest extends HCaptchaPluginWPTestCase { + + /** + * Theme stylesheet slug. + * + * @var string + */ + protected static string $theme = 'Divi'; + + /** + * Live Divi shortcode modules used by the test. + * + * @var array + */ + protected static array $theme_shortcode_classes = [ + 'et_pb_comments' => 'ET_Builder_Module_Comments', + ]; + + /** + * Expected incorrect usage notices caused by the late theme load. + * + * @var string[] + */ + protected static array $theme_expected_incorrect_usage = [ "add_theme_support( 'title-tag' )" ]; /** * Test constructor and init_hooks(). + * + * @return void */ public function test_constructor_and_init_hooks(): void { $subject = new Comment(); + self::assertTrue( function_exists( 'et_setup_builder' ) ); + self::assertNotFalse( has_action( 'init', 'et_setup_builder' ) ); + self::assertTrue( class_exists( 'ET_Builder_Module_Comments' ) ); + self::assertTrue( shortcode_exists( 'et_pb_comments' ) ); self::assertSame( 10, has_filter( Comment::TAG . '_shortcode_output', [ $subject, 'add_captcha' ] ) ); } /** - * Test add_captcha(). + * Test the live Divi Comments module. + * + * @return void */ - public function test_add_captcha(): void { - $form_id = 3075; - $output = << - - - -HTML; - $hcap_form = $this->get_hcap_form( + public function test_live_comments_module(): void { + $post_id = $this->factory()->post->create( [ - 'action' => Comment::ACTION, - 'name' => Comment::NONCE, - 'id' => [ - 'source' => [ 'WordPress' ], - 'form_id' => $form_id, - ], + 'post_status' => 'publish', + 'comment_status' => 'open', ] ); - $expected = str_replace( 'go_to( get_permalink( $post_id ) ); + update_option( 'hcaptcha_settings', [ 'divi_status' => [ 'comment' ] ] ); + hcaptcha()->init_hooks(); - $subject = new Comment(); + new Comment(); + + self::assertTrue( shortcode_exists( 'et_pb_comments' ) ); + + $output = do_shortcode( '[et_pb_comments][/et_pb_comments]' ); - self::assertSame( $expected, $subject->add_captcha( $output, $module_slug ) ); + self::assertStringContainsString( 'et_pb_comments_module', $output ); + self::assertStringContainsString( 'id="commentform"', $output ); + self::assertStringContainsString( 'add_captcha( $output, $module_slug ) ); + self::assertSame( $output, $subject->add_captcha( $output, Comment::TAG ) ); } /** - * Test add_captcha() when the output has hCaptcha. + * Test add_captcha() when the output already has hCaptcha. + * + * @return void */ public function test_add_captcha_when_output_has_hcaptcha(): void { - $output = 'some output with h-captcha attr'; - $module_slug = 'et_pb_comments'; + $output = 'some output with h-captcha attr'; $subject = new Comment(); - self::assertSame( $output, $subject->add_captcha( $output, $module_slug ) ); + self::assertSame( $output, $subject->add_captcha( $output, Comment::TAG ) ); } /** - * Test add_captcha() in the frontend builder. + * Test add_captcha() in the live Divi frontend builder state. + * + * @return void + * @noinspection PhpUndefinedFunctionInspection */ public function test_add_captcha_in_frontend_builder(): void { - $output = 'some string'; - $module_slug = 'et_pb_comments'; + $output = 'some string'; - FunctionMocker::replace( 'et_core_is_fb_enabled', true ); + add_filter( 'et_fb_is_enabled', '__return_true' ); $subject = new Comment(); - self::assertSame( $output, $subject->add_captcha( $output, $module_slug ) ); + self::assertTrue( et_core_is_fb_enabled() ); + self::assertSame( $output, $subject->add_captcha( $output, Comment::TAG ) ); } } diff --git a/tests/php/integration/Divi/ContactTest.php b/tests/php/integration/Divi/ContactTest.php index b4c80c520..1aa778731 100644 --- a/tests/php/integration/Divi/ContactTest.php +++ b/tests/php/integration/Divi/ContactTest.php @@ -7,13 +7,9 @@ namespace HCaptcha\Tests\Integration\Divi; -use ET\Builder\FrontEnd\BlockParser\BlockParserStore; use HCaptcha\Divi\Contact; -use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use ReflectionException; -use stdClass; -use tad\FunctionMocker\FunctionMocker; use WP_Block; /** @@ -21,41 +17,37 @@ * * @group divi */ -class ContactTest extends HCaptchaWPTestCase { +class ContactTest extends HCaptchaPluginWPTestCase { /** - * Contact form nonce field. + * Theme stylesheet slug. * * @var string */ - private string $cf_nonce_field = '_wpnonce-et-pb-contact-form-submitted-0'; + protected static string $theme = 'Divi'; /** - * Contact form submit field. + * Live Divi shortcode modules used by the test. * - * @var string + * @var array */ - private string $submit_field = 'et_pb_contactform_submit_0'; + protected static array $theme_shortcode_classes = [ + 'et_pb_contact_form' => 'ET_Builder_Module_Contact_Form', + ]; /** - * Contact form current form field. + * Expected incorrect usage notices caused by the late theme load. * - * @var string + * @var string[] */ - private string $current_form_field = 'et_pb_contact_email_fields_0'; + protected static array $theme_expected_incorrect_usage = [ "add_theme_support( 'title-tag' )" ]; /** * Tear down the test. + * + * @return void */ public function tearDown(): void { - // phpcs:disable WordPress.Security.NonceVerification.Missing - unset( - $_SERVER['REQUEST_METHOD'], - $_POST[ $this->cf_nonce_field ], - $_POST[ $this->submit_field ] - ); - // phpcs:enable WordPress.Security.NonceVerification.Missing - wp_dequeue_script( 'et-core-api-spam-recaptcha' ); wp_dequeue_script( 'hcaptcha-divi' ); @@ -64,230 +56,90 @@ public function tearDown(): void { /** * Test constructor and init_hooks(). + * + * @return void */ public function test_constructor_and_init_hooks(): void { $subject = new Contact(); - self::assertSame( - 10, - has_filter( 'et_pb_contact_form_shortcode_output', [ $subject, 'add_hcaptcha' ] ) - ); - self::assertSame( - 10, - has_filter( 'pre_do_shortcode_tag', [ $subject, 'verify_4' ] ) - ); - - self::assertSame( - 10, - has_filter( 'et_pb_module_shortcode_attributes', [ $subject, 'shortcode_attributes' ] ) - ); - self::assertSame( - 9, - has_action( 'wp_print_footer_scripts', [ $subject, 'enqueue_scripts' ] ) - ); + self::assertSame( 10, has_filter( 'et_pb_contact_form_shortcode_output', [ $subject, 'add_hcaptcha' ] ) ); + self::assertSame( 10, has_filter( 'pre_do_shortcode_tag', [ $subject, 'verify_4' ] ) ); + self::assertSame( 10, has_filter( 'et_pb_module_shortcode_attributes', [ $subject, 'shortcode_attributes' ] ) ); + self::assertSame( 9, has_action( 'wp_print_footer_scripts', [ $subject, 'enqueue_scripts' ] ) ); } /** - * Test add_captcha(). + * Test the live Divi Contact Form module. * - * @throws ReflectionException ReflectionException. - */ - public function test_add_captcha(): void { - FunctionMocker::replace( 'et_core_is_fb_enabled', false ); - - $output = ' -
- - - -

Make sure you entered the captcha.

- -
-
-

- - - - -

- - - - -

- - - - -

- -
- -
-

- 3 + 13 = -

-
- -
- -
-
-
- '; - - $module_slug = 'et_pb_contact_form'; - - $hcap_form = $this->get_hcap_form( - [ - 'action' => 'hcaptcha_divi_cf', - 'name' => 'hcaptcha_divi_cf_nonce', - 'id' => [ - 'source' => [ 'Divi' ], - 'form_id' => 'contact', - ], - ] - ); - $expected = ' -
- - - -

Make sure you entered the captcha.

- -
-
-

- - - - -

- - - - -

- - - - -

- -
' . $hcap_form . '
-
-
- - - -
- -
-
-
- '; - - hcaptcha()->init_hooks(); - - $subject = new Contact(); - - self::assertSame( 0, $this->get_protected_property( $subject, 'render_count' ) ); - self::assertSame( $expected, $subject->add_hcaptcha( $output, $module_slug ) ); - self::assertSame( 1, $this->get_protected_property( $subject, 'render_count' ) ); - } - - /** - * Test add_hcaptcha() with built-in form interaction. + * @return void */ - public function test_add_captcha_with_form_interaction(): void { - FunctionMocker::replace( 'et_core_is_fb_enabled', false ); - - $output = '
'; - $module_slug = 'et_pb_contact_form'; - + public function test_live_contact_form_module(): void { + update_option( 'hcaptcha_settings', [ 'divi_status' => [ 'contact' ] ] ); hcaptcha()->init_hooks(); - add_filter( 'hcap_delay_api_event', '__return_true' ); + new Contact(); - try { - $actual = ( new Contact() )->add_hcaptcha( $output, $module_slug ); - } finally { - remove_filter( 'hcap_delay_api_event', '__return_true' ); - } + $output = do_shortcode( '[et_pb_contact_form captcha="on"][/et_pb_contact_form]' ); - self::assertStringContainsString( - 'class="h-captcha hcaptcha-api-delayed"', - $actual - ); + self::assertStringContainsString( 'et_pb_contact_form_container', $output ); + self::assertStringContainsString( 'name="et_pb_contactform_submit_', $output ); + self::assertStringContainsString( 'class="hcaptcha-divi-wrapper"', $output ); + self::assertStringContainsString( 'init_hooks(); - - $hcap_form = $this->get_hcap_form( + $block_content = '
'; + $subject = new Contact(); + $block = new WP_Block( [ - 'action' => 'hcaptcha_divi_cf', - 'name' => 'hcaptcha_divi_cf_nonce', - 'id' => [ - 'source' => [ 'Divi' ], - 'form_id' => 'contact', - ], + 'blockName' => 'divi/contact-form', + 'attrs' => [], + 'innerBlocks' => [], + 'innerHTML' => '', + 'innerContent' => [], ] ); - $search = '
'; - $block_content = '
' . "\n" . $search . "\n" . '
'; - $expected = - '
' . - "\n" . - '
' . $hcap_form . '
' . - "\n" . - '
' . - "\n" . - $search . - "\n" . - '
'; - - $subject = new Contact(); - $dummy_wp_block = [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - 'innerBlocks' => [], - 'innerHTML' => '', - 'innerContent' => [], - ]; - - // Wrong block. self::assertSame( $block_content, - $subject->add_hcaptcha_to_block( $block_content, [ 'blockName' => 'core/paragraph' ], new WP_Block( $dummy_wp_block ) ) + $subject->add_hcaptcha_to_block( $block_content, [ 'blockName' => 'core/paragraph' ], $block ) ); - // Contact Form block. - self::assertSame( - $expected, - $subject->add_hcaptcha_to_block( $block_content, [ 'blockName' => 'divi/contact-form' ], new WP_Block( $dummy_wp_block ) ) + $output = $subject->add_hcaptcha_to_block( + $block_content, + [ 'blockName' => 'divi/contact-form' ], + $block ); + + self::assertStringContainsString( 'class="hcaptcha-divi-5-wrapper"', $output ); + self::assertStringContainsString( 'get_protected_property( $subject, 'render_count' ) ); - self::assertSame( $output, $subject->add_hcaptcha( $output, $module_slug ) ); + self::assertSame( $output, $subject->add_hcaptcha( $output, 'et_pb_contact_form' ) ); self::assertSame( 0, $this->get_protected_property( $subject, 'render_count' ) ); } @@ -322,283 +174,18 @@ public function test_filter_fields_data_rejects_nested_values(): void { $subject = new Contact(); $method = $this->set_method_accessibility( $subject, 'filter_fields_data' ); - self::assertSame( - [ $valid ], - $method->invoke( - $subject, - array_merge( [ $valid ], $malformed ) - ) - ); + self::assertSame( [ $valid ], $method->invoke( $subject, array_merge( [ $valid ], $malformed ) ) ); } /** - * Test verify_4(). + * Test verify_4() with the wrong shortcode tag. * - * @throws ReflectionException ReflectionException. - */ - public function test_verify_4(): void { - $return = 'some html'; - $tag = 'et_pb_contact_form'; - - $nonce = wp_create_nonce( 'et-pb-contact-form-submit' ); - $_POST[ $this->cf_nonce_field ] = $nonce; - - $_POST[ $this->submit_field ] = 'submit'; - - $current_form_fields = '[{"field_id":"et_pb_contact_name_0","original_id":"name","required_mark":"required","field_type":"input","field_label":"Name"},{"field_id":"et_pb_contact_email_0","original_id":"email","required_mark":"required","field_type":"email","field_label":"Email Address"},{"field_id":"et_pb_contact_message_0","original_id":"message","required_mark":"required","field_type":"text","field_label":"Message"},{"field_id":"et_pb_contact_some_0","original_id":"some","required_mark":"required","field_type":"some","field_label":"Some"},{"field_id":"h-captcha-response-0lwsv53iy61b","original_id":"","required_mark":"not_required","field_type":"text","field_label":""}]'; - $_POST[ $this->current_form_field ] = $current_form_fields; - $expected_current_form_fields = '[{\"field_id\":\"et_pb_contact_name_0\",\"original_id\":\"name\",\"required_mark\":\"required\",\"field_type\":\"input\",\"field_label\":\"Name\"},{\"field_id\":\"et_pb_contact_email_0\",\"original_id\":\"email\",\"required_mark\":\"required\",\"field_type\":\"email\",\"field_label\":\"Email Address\"},{\"field_id\":\"et_pb_contact_message_0\",\"original_id\":\"message\",\"required_mark\":\"required\",\"field_type\":\"text\",\"field_label\":\"Message\"},{\"field_id\":\"et_pb_contact_some_0\",\"original_id\":\"some\",\"required_mark\":\"required\",\"field_type\":\"some\",\"field_label\":\"Some\"}]'; - - $this->prepare_verify_post_html( 'hcaptcha_divi_cf_nonce', 'hcaptcha_divi_cf' ); - $this->prepare_widget_id(); - - FunctionMocker::replace( - 'filter_input', - function ( $type, $var_name, $filter ) use ( $nonce, $current_form_fields ) { - if ( - INPUT_POST === $type && - $this->cf_nonce_field === $var_name && - FILTER_SANITIZE_FULL_SPECIAL_CHARS === $filter - ) { - return $nonce; - } - - if ( - INPUT_POST === $type && - $this->current_form_field === $var_name && - FILTER_SANITIZE_FULL_SPECIAL_CHARS === $filter - ) { - return $current_form_fields; - } - - return null; - } - ); - - $subject = new Contact(); - - self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); - self::assertEquals( $return, $subject->verify_4( $return, $tag, [], [] ) ); - - // phpcs:disable WordPress.Security.NonceVerification.Missing - // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotValidated - // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash - self::assertEquals( $expected_current_form_fields, $_POST[ $this->current_form_field ] ); - // phpcs:enable WordPress.Security.NonceVerification.Missing - // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotValidated - // phpcs:enable WordPress.Security.ValidatedSanitizedInput.MissingUnslash - - self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); - } - - /** - * Test verify_4() not verified. - * - * @throws ReflectionException ReflectionException. - */ - public function test_verify_4_not_verified(): void { - $return = 'some html'; - $tag = 'et_pb_contact_form'; - - $nonce = wp_create_nonce( 'et-pb-contact-form-submit' ); - $_POST[ $this->cf_nonce_field ] = $nonce; - - $_POST[ $this->submit_field ] = 'submit'; - - $current_form_fields = '[{"field_id":"et_pb_contact_name_0","original_id":"name","required_mark":"required","field_type":"input","field_label":"Name"},{"field_id":"et_pb_contact_email_0","original_id":"email","required_mark":"required","field_type":"email","field_label":"Email Address"},{"field_id":"et_pb_contact_message_0","original_id":"message","required_mark":"required","field_type":"text","field_label":"Message"},{"field_id":"h-captcha-response-0lwsv53iy61b","original_id":"","required_mark":"not_required","field_type":"text","field_label":""}]'; - $_POST[ $this->current_form_field ] = $current_form_fields; - $expected_current_form_fields = '[{\"field_id\":\"et_pb_contact_name_0\",\"original_id\":\"name\",\"required_mark\":\"required\",\"field_type\":\"input\",\"field_label\":\"Name\"},{\"field_id\":\"et_pb_contact_email_0\",\"original_id\":\"email\",\"required_mark\":\"required\",\"field_type\":\"email\",\"field_label\":\"Email Address\"},{\"field_id\":\"et_pb_contact_message_0\",\"original_id\":\"message\",\"required_mark\":\"required\",\"field_type\":\"text\",\"field_label\":\"Message\"}]'; - - $this->prepare_verify_post_html( 'hcaptcha_divi_cf_nonce', 'hcaptcha_divi_cf', false ); - $this->prepare_widget_id(); - - FunctionMocker::replace( - 'filter_input', - function ( $type, $var_name, $filter ) use ( $nonce, $current_form_fields ) { - if ( - INPUT_POST === $type && - $this->cf_nonce_field === $var_name && - FILTER_SANITIZE_FULL_SPECIAL_CHARS === $filter - ) { - return $nonce; - } - - if ( - INPUT_POST === $type && - $this->current_form_field === $var_name && - FILTER_SANITIZE_FULL_SPECIAL_CHARS === $filter - ) { - return $current_form_fields; - } - - return null; - } - ); - - $subject = new Contact(); - - self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); - self::assertEquals( $return, $subject->verify_4( $return, $tag, [], [] ) ); - - // phpcs:disable WordPress.Security.NonceVerification.Missing - // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotValidated - // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash - self::assertEquals( $expected_current_form_fields, $_POST[ $this->current_form_field ] ); - // phpcs:enable WordPress.Security.NonceVerification.Missing - // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotValidated - // phpcs:enable WordPress.Security.ValidatedSanitizedInput.MissingUnslash - - self::assertSame( 'on', $this->get_protected_property( $subject, 'captcha' ) ); - } - - /** - * Test verify_4() with bad widget id. - * - * @throws ReflectionException ReflectionException. - */ - public function test_verify_4_bad_widget_id(): void { - $return = 'some html'; - $tag = 'et_pb_contact_form'; - - $nonce = wp_create_nonce( 'et-pb-contact-form-submit' ); - $_POST[ $this->cf_nonce_field ] = $nonce; - $_POST[ $this->submit_field ] = 'submit'; - - $current_form_fields = '[{"field_id":"et_pb_contact_name_0","original_id":"name","required_mark":"required","field_type":"input","field_label":"Name"},{"field_id":"et_pb_contact_email_0","original_id":"email","required_mark":"required","field_type":"email","field_label":"Email Address"},{"field_id":"et_pb_contact_message_0","original_id":"message","required_mark":"required","field_type":"text","field_label":"Message"}]'; - $_POST[ $this->current_form_field ] = $current_form_fields; - - $this->prepare_verify_post_html( 'hcaptcha_divi_cf_nonce', 'hcaptcha_divi_cf' ); - $this->prepare_widget_id( - [ - 'source' => [ 'WordPress' ], - 'form_id' => 'contact', - ] - ); - - FunctionMocker::replace( - 'filter_input', - function ( $type, $var_name, $filter ) use ( $nonce, $current_form_fields ) { - if ( - INPUT_POST === $type && - $this->cf_nonce_field === $var_name && - FILTER_SANITIZE_FULL_SPECIAL_CHARS === $filter - ) { - return $nonce; - } - - if ( - INPUT_POST === $type && - $this->current_form_field === $var_name && - FILTER_SANITIZE_FULL_SPECIAL_CHARS === $filter - ) { - return $current_form_fields; - } - - return null; - } - ); - - $subject = new Contact(); - - self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); - self::assertEquals( $return, $subject->verify_4( $return, $tag, [], [] ) ); - self::assertSame( 'on', $this->get_protected_property( $subject, 'captcha' ) ); - } - - /** - * Test verify_4() with the wrong tag. - */ - public function test_verify_4_wrong_tag(): void { - $return = 'some html'; - $tag = 'wrong tag'; - - $subject = new Contact(); - - self::assertEquals( $return, $subject->verify_4( $return, $tag, [], [] ) ); - } - - /** - * Test verify_5(). - * - * @throws ReflectionException ReflectionException. + * @return void */ - public function test_verify_5(): void { - $nonce = wp_create_nonce( 'et-pb-contact-form-submit' ); - $_POST[ $this->cf_nonce_field ] = $nonce; - $_POST[ $this->submit_field ] = 'submit'; - - $current_form_fields = '[{"field_id":"et_pb_contact_name_0","original_id":"name","required_mark":"required","field_type":"input","field_label":"Name"},{"field_id":"et_pb_contact_email_0","original_id":"email","required_mark":"required","field_type":"email","field_label":"Email Address"},{"field_id":"et_pb_contact_message_0","original_id":"message","required_mark":"required","field_type":"text","field_label":"Message"},{"field_id":"h-captcha-response-0lwsv53iy61b","original_id":"","required_mark":"not_required","field_type":"text","field_label":""}]'; - $_POST[ $this->current_form_field ] = $current_form_fields; - - $this->prepare_verify_post_html( 'hcaptcha_divi_cf_nonce', 'hcaptcha_divi_cf', false ); - $this->prepare_widget_id(); - - FunctionMocker::replace( - 'filter_input', - function ( $type, $var_name, $filter ) use ( $nonce, $current_form_fields ) { - if ( - INPUT_POST === $type && - $this->cf_nonce_field === $var_name && - FILTER_SANITIZE_FULL_SPECIAL_CHARS === $filter - ) { - return $nonce; - } - - if ( - INPUT_POST === $type && - $this->current_form_field === $var_name && - FILTER_SANITIZE_FULL_SPECIAL_CHARS === $filter - ) { - return $current_form_fields; - } - - return null; - } - ); - - $module = new stdClass(); - $module->attrs = []; - $module->attrs['module'] = []; - - $module->attrs['module']['advanced']['spamProtection']['desktop']['value']['enabled'] = 'off'; - - $filter_args = [ - 'name' => 'divi/some', - 'id' => 'module-1', - 'storeInstance' => 'store-1', - ]; - + public function test_verify_4_with_wrong_tag(): void { $subject = new Contact(); - // 1. Wrong request method. - $_SERVER['REQUEST_METHOD'] = 'GET'; - self::assertSame( [], $subject->verify_5( [], $filter_args ) ); - - // 2. Request method POST. Wrong filter args. - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $_SERVER['REQUEST_METHOD'] = 'POST'; - - self::assertSame( [], $subject->verify_5( [], $filter_args ) ); - - // 3. Request method POST. Correct filter args. Wrong module. - $filter_args['name'] = 'divi/contact-form'; - - // Put a module in the store so Contact::verify_5 can mutate it. - BlockParserStore::$module = null; - - self::assertSame( [], $subject->verify_5( [], $filter_args ) ); - - // 4. Request method POST. Correct filter args. Correct module. - BlockParserStore::$module = $module; - - self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); - self::assertSame( [], $subject->verify_5( [], $filter_args ) ); - self::assertSame( 'on', $this->get_protected_property( $subject, 'captcha' ) ); - self::assertSame( 'on', $module->attrs['module']['advanced']['spamProtection']['desktop']['value']['enabled'] ); + self::assertSame( 'some html', $subject->verify_4( 'some html', 'wrong-tag', [], [] ) ); } /** @@ -608,15 +195,12 @@ function ( $type, $var_name, $filter ) use ( $nonce, $current_form_fields ) { * @param string $own_captcha Own captcha in Contact class. * * @dataProvider dp_test_shortcode_attributes + * @return void * @throws ReflectionException ReflectionException. * @noinspection PhpMissingParamTypeInspection */ public function test_shortcode_attributes( $captcha, string $own_captcha ): void { - $props = [ 'foo' => 'bar' ]; - $attrs = []; - $slug = 'et_pb_contact_form'; - $_address = '0.0.0.0'; - $content = 'some content'; + $props = [ 'foo' => 'bar' ]; if ( $captcha ) { $props['captcha'] = $captcha; @@ -627,15 +211,17 @@ public function test_shortcode_attributes( $captcha, string $own_captcha ): void $expected['use_spam_service'] = $own_captcha; $subject = new Contact(); - self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); $this->set_protected_property( $subject, 'captcha', $own_captcha ); - self::assertSame( $expected, $subject->shortcode_attributes( $props, $attrs, $slug, $_address, $content ) ); + self::assertSame( + $expected, + $subject->shortcode_attributes( $props, [], 'et_pb_contact_form', '0.0.0.0', 'some content' ) + ); self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); } /** - * Data provider for dp_test_shortcode_attributes(). + * Data provider for test_shortcode_attributes(). * * @return array */ @@ -653,29 +239,19 @@ public function dp_test_shortcode_attributes(): array { /** * Test shortcode_attributes() with the wrong slug. * - * @throws ReflectionException ReflectionException. + * @return void */ public function test_shortcode_attributes_with_wrong_slug(): void { - $props = [ - 'foo' => 'bar', - 'captcha' => 'some', - ]; - $attrs = []; - $slug = 'wrong'; - $_address = '0.0.0.0'; - $content = 'some content'; - - $expected = $props; - + $props = [ 'captcha' => 'some' ]; $subject = new Contact(); - self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); - self::assertSame( $expected, $subject->shortcode_attributes( $props, $attrs, $slug, $_address, $content ) ); - self::assertSame( 'off', $this->get_protected_property( $subject, 'captcha' ) ); + self::assertSame( $props, $subject->shortcode_attributes( $props, [], 'wrong', '0.0.0.0', '' ) ); } /** * Test print_inline_styles(). + * + * @return void */ public function test_print_inline_styles(): void { $subject = new Contact(); @@ -692,40 +268,17 @@ public function test_print_inline_styles(): void { /** * Test enqueue_scripts(). + * + * @return void */ public function test_enqueue_scripts(): void { hcaptcha()->form_shown = true; - wp_register_script( - 'et-recaptcha-v3', - 'https://www.google.com/recaptcha/api.js?render=some-site-key', - [], - '1.0.0', - true - ); - wp_register_script( - 'es6-promise', - 'https://example.com/admin/js/es6-promise.auto.min.js', - [], - '1.0.0', - true - ); - - wp_enqueue_script( - 'et-core-api-spam-recaptcha', - 'https://example.com/recaptcha.js', - [], - '1.0.0', - true - ); + wp_register_script( 'et-recaptcha-v3', 'https://example.com/recaptcha-api.js', [], '1.0.0', true ); + wp_register_script( 'es6-promise', 'https://example.com/es6-promise.js', [], '1.0.0', true ); + wp_enqueue_script( 'et-core-api-spam-recaptcha', 'https://example.com/recaptcha.js', [], '1.0.0', true ); $subject = new Contact(); - - self::assertTrue( wp_script_is( 'et-recaptcha-v3', 'registered' ) ); - self::assertTrue( wp_script_is( 'es6-promise', 'registered' ) ); - self::assertTrue( wp_script_is( 'et-core-api-spam-recaptcha' ) ); - self::assertFalse( wp_script_is( 'hcaptcha-divi' ) ); - $subject->enqueue_scripts(); self::assertFalse( wp_script_is( 'et-recaptcha-v3', 'registered' ) ); @@ -735,41 +288,17 @@ public function test_enqueue_scripts(): void { } /** - * Test enqueue_scripts(). + * Test enqueue_scripts() when hCaptcha was not shown. + * + * @return void */ public function test_enqueue_scripts_when_hcaptcha_was_not_shown(): void { - wp_enqueue_script( - 'et-core-api-spam-recaptcha', - 'https://example.com/recaptcha.js', - [], - '1.0.0', - true - ); + wp_enqueue_script( 'et-core-api-spam-recaptcha', 'https://example.com/recaptcha.js', [], '1.0.0', true ); $subject = new Contact(); - - self::assertTrue( wp_script_is( 'et-core-api-spam-recaptcha' ) ); - self::assertFalse( wp_script_is( 'hcaptcha-divi' ) ); - $subject->enqueue_scripts(); self::assertTrue( wp_script_is( 'et-core-api-spam-recaptcha' ) ); self::assertFalse( wp_script_is( 'hcaptcha-divi' ) ); } - - /** - * Prepare hCaptcha widget id. - * - * @param array $id The hCaptcha widget id. - * - * @return void - */ - private function prepare_widget_id( array $id = [] ): void { - $id = $id ?: [ - 'source' => [ 'Divi' ], - 'form_id' => 'contact', - ]; - - $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); - } } diff --git a/tests/php/integration/Divi/EmailOptinTest.php b/tests/php/integration/Divi/EmailOptinTest.php index 3af8d5b3e..41f2463c0 100644 --- a/tests/php/integration/Divi/EmailOptinTest.php +++ b/tests/php/integration/Divi/EmailOptinTest.php @@ -14,8 +14,7 @@ use HCaptcha\Divi\EmailOptin; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use tad\FunctionMocker\FunctionMocker; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; /** * Class EmailOptinTest @@ -23,7 +22,30 @@ * @group divi * @group divi-email-optin */ -class EmailOptinTest extends HCaptchaWPTestCase { +class EmailOptinTest extends HCaptchaPluginWPTestCase { + + /** + * Theme stylesheet slug. + * + * @var string + */ + protected static string $theme = 'Divi'; + + /** + * Live Divi shortcode modules used by the test. + * + * @var array + */ + protected static array $theme_shortcode_classes = [ + 'et_pb_signup' => 'ET_Builder_Module_Signup', + ]; + + /** + * Expected incorrect usage notices caused by the late theme load. + * + * @var string[] + */ + protected static array $theme_expected_incorrect_usage = [ "add_theme_support( 'title-tag' )" ]; /** * Tear down the test. @@ -47,33 +69,24 @@ public function test_constructor_and_init_hooks(): void { } /** - * Test add_captcha(). + * Test the live Divi Email Opt-in module. + * + * @return void */ - public function test_add_captcha(): void { - $wrap = '

'; - $html = << - $wrap - -HTML; - $hcap_form = $this->get_hcap_form( - [ - 'action' => EmailOptin::ACTION, - 'name' => EmailOptin::NONCE, - 'id' => [ - 'source' => [ 'Divi' ], - 'form_id' => 'email_optin', - ], - ] - ); - $expected = str_replace( $wrap, $hcap_form . "\n" . $wrap, $html ); - $single_name_field = 'some'; + public function test_live_email_optin_module(): void { + update_option( 'hcaptcha_settings', [ 'divi_status' => [ 'email_optin' ] ] ); + hcaptcha()->init_hooks(); - FunctionMocker::replace( 'et_core_is_fb_enabled', false ); + new EmailOptin(); - $subject = new EmailOptin(); + $output = do_shortcode( + '[et_pb_signup provider="feedburner" feedburner_uri="hcaptcha-test"][/et_pb_signup]' + ); - self::assertSame( $expected, $subject->add_captcha( $html, $single_name_field ) ); + self::assertStringContainsString( 'et_pb_feedburner_form', $output ); + self::assertStringContainsString( 'feedburner.google.com/fb/a/mailverify', $output ); + self::assertStringContainsString( ' + */ + protected static array $theme_shortcode_classes = [ + 'et_pb_login' => 'ET_Builder_Module_Login', + ]; + + /** + * Expected incorrect usage notices caused by the late theme load. + * + * @var string[] + */ + protected static array $theme_expected_incorrect_usage = [ "add_theme_support( 'title-tag' )" ]; /** * Test constructor and init_hooks(). + * + * @return void */ public function test_constructor_and_init_hooks(): void { $subject = new Login(); @@ -30,279 +54,114 @@ public function test_constructor_and_init_hooks(): void { } /** - * Test add_divi_captcha(). + * Test the live Divi Login module. + * + * @return void */ - public function test_add_divi_captcha(): void { - FunctionMocker::replace( 'et_core_is_fb_enabled', false ); - - $output = '

'; + public function test_live_login_module(): void { + wp_set_current_user( 0 ); + $this->enable_login_integration(); - $module_slug = 'et_pb_login'; - $signature = $this->get_encoded_signature( Login::class, [ 'Divi' ], 'login', true ); + new Login(); - $hcap_form = $this->get_hcap_form( - [ - 'action' => 'hcaptcha_login', - 'name' => 'hcaptcha_login_nonce', - 'id' => [ - 'source' => [ 'Divi' ], - 'form_id' => 'login', - ], - ] - ); - $expected = ''; - - update_option( - 'hcaptcha_settings', - [ - 'divi_status' => [ 'login' ], - ] - ); - - add_filter( - 'template', - static function () { - return 'Divi'; - } - ); + $output = do_shortcode( '[et_pb_login title="Login"][/et_pb_login]' ); - hcaptcha()->init_hooks(); - - $subject = new Login(); - - self::assertSame( $expected, $subject->add_hcaptcha_to_shortcode( $output, $module_slug ) ); + self::assertStringContainsString( 'et_pb_login_form', $output ); + self::assertStringContainsString( 'name="log"', $output ); + self::assertStringContainsString( 'add_hcaptcha_to_shortcode( $output, $module_slug ) ); + self::assertTrue( et_core_is_fb_enabled() ); + self::assertSame( $output, $subject->add_hcaptcha_to_shortcode( $output, Login::TAG ) ); } /** - * Test add_divi_captcha() when the login limit is not exceeded. + * Test add_hcaptcha_to_shortcode() when the login limit is not exceeded. + * + * @return void */ - public function test_add_divi_captcha_when_login_limit_is_not_exceeded(): void { - $output = 'some string'; - $module_slug = 'et_pb_login'; + public function test_add_hcaptcha_when_login_limit_is_not_exceeded(): void { + $output = 'some string'; add_filter( 'hcap_login_limit_exceeded', '__return_false' ); $subject = new Login(); - self::assertSame( $output, $subject->add_hcaptcha_to_shortcode( $output, $module_slug ) ); + self::assertSame( $output, $subject->add_hcaptcha_to_shortcode( $output, Login::TAG ) ); } /** - * Test add_hcaptcha_to_block(). + * Test add_hcaptcha_to_block() with output from the live Login module. + * + * @return void */ public function test_add_hcaptcha_to_block(): void { - FunctionMocker::replace( 'et_core_is_fb_enabled', false ); + wp_set_current_user( 0 ); + $this->enable_login_integration(); - $output = ''; + $output = do_shortcode( '[et_pb_login title="Login"][/et_pb_login]' ); - $signature = $this->get_encoded_signature( Login::class, [ 'Divi' ], 'login', true ); + self::assertStringNotContainsString( 'get_hcap_form( - [ - 'action' => 'hcaptcha_login', - 'name' => 'hcaptcha_login_nonce', - 'id' => [ - 'source' => [ 'Divi' ], - 'form_id' => 'login', - ], - ] - ); - $expected = ''; - - update_option( - 'hcaptcha_settings', + $subject = new Login(); + $block = new WP_Block( [ - 'divi_status' => [ 'login' ], + 'blockName' => 'divi/login', + 'attrs' => [], + 'innerBlocks' => [], + 'innerHTML' => '', + 'innerContent' => [], ] ); - add_filter( - 'template', - static function () { - return 'Divi'; - } - ); - - hcaptcha()->init_hooks(); - - $subject = new Login(); - $dummy_wp_block = [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - 'innerBlocks' => [], - 'innerHTML' => '', - 'innerContent' => [], - ]; - - // Wrong block. self::assertSame( $output, - $subject->add_hcaptcha_to_block( $output, [ 'blockName' => 'core/paragraph' ], new WP_Block( $dummy_wp_block ) ) + $subject->add_hcaptcha_to_block( $output, [ 'blockName' => 'core/paragraph' ], $block ) ); - // Login block. - self::assertSame( - $expected, - $subject->add_hcaptcha_to_block( $output, [ 'blockName' => 'divi/login' ], new WP_Block( $dummy_wp_block ) ) - ); + $output = $subject->add_hcaptcha_to_block( $output, [ 'blockName' => 'divi/login' ], $block ); + + self::assertStringContainsString( 'et_pb_login_form', $output ); + self::assertStringContainsString( 'makePartial(); - - $subject->shouldAllowMockingProtectedMethods(); - - // Divi Builder plugin is active. - self::assertSame( 'divi_builder', $subject->get_active_divi_component() ); - - // No Divi component is active. - $builder_active = false; - - self::assertSame( '', $subject->get_active_divi_component() ); - - // Divi theme is active. - add_filter( - 'template', - static function () use ( &$template ) { - return $template; - } - ); - - $template = 'Divi'; - - self::assertSame( 'divi', $subject->get_active_divi_component() ); + $subject = new Login(); + $method = $this->set_method_accessibility( $subject, 'get_active_divi_component' ); - // Extra theme is active. - $template = 'Extra'; + self::assertSame( 'Divi', get_template() ); + self::assertSame( 'divi', $method->invoke( $subject ) ); + } - self::assertSame( 'extra', $subject->get_active_divi_component() ); + /** + * Enable the Divi Login integration. + * + * @return void + */ + private function enable_login_integration(): void { + update_option( 'hcaptcha_settings', [ 'divi_status' => [ 'login' ] ] ); + hcaptcha()->init_hooks(); } } diff --git a/tests/php/integration/DownloadManager/DownloadManagerTest.php b/tests/php/integration/DownloadManager/DownloadManagerTest.php index e15b86b64..33094664b 100644 --- a/tests/php/integration/DownloadManager/DownloadManagerTest.php +++ b/tests/php/integration/DownloadManager/DownloadManagerTest.php @@ -14,15 +14,44 @@ use HCaptcha\DownloadManager\DownloadManager; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use tad\FunctionMocker\FunctionMocker; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; +use ReflectionClass; +use WPDM\Package\PackageController; /** * Test DownloadManager class. * * @group download-manager */ -class DownloadManagerTest extends HCaptchaWPTestCase { +class DownloadManagerTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative path. + * + * @var string + */ + protected static $plugin = 'download-manager/download-manager.php'; + + /** + * Download Manager redirects and exits from its activated_plugin callback. + * + * @var string[] + */ + protected static array $plugin_silent_activation = [ 'download-manager/download-manager.php' ]; + + /** + * Hooks to replay after loading Download Manager. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ 'init' ]; + + /** + * Register Download Manager's post type after the WordPress test bootstrap. + * + * @var bool + */ + protected static bool $force_plugin_load_hooks = true; /** * Test init_hooks(). @@ -155,6 +184,39 @@ public function test_add_hcaptcha(): void { self::assertSame( $expected, $subject->add_hcaptcha( $template, $vars ) ); } + /** + * Test hCaptcha in a package rendered by the live Download Manager template. + * + * @return void + */ + public function test_live_package_template(): void { + $package_id = PackageController::create( + [ + 'post_title' => 'Live Download', + 'post_content' => 'Download Manager package content.', + 'post_status' => 'publish', + 'template' => 'link-template-panel.php', + 'files' => [ 'live-download.txt' ], + 'access' => [ 'guest' ], + ] + ); + $subject = new DownloadManager(); + $controller = new ReflectionClass( PackageController::class ); + $html = WPDM()->package->fetchTemplate( 'page-template-default.php', $package_id, 'page' ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertSame( 'wpdmpro', get_post_type( $package_id ) ); + self::assertStringStartsWith( + wp_normalize_path( WP_PLUGIN_DIR . '/download-manager/' ), + wp_normalize_path( (string) $controller->getFileName() ) + ); + self::assertStringContainsString( 'wpdm-button-area', $html ); + self::assertStringContainsString( '
print_inline_styles(); + $css = (string) ob_get_clean(); - self::assertSame( $expected, ob_get_clean() ); + self::assertStringContainsString( '\n"; - $subject = new Form(); ob_start(); $subject->print_inline_styles(); + $css = (string) ob_get_clean(); - self::assertSame( $expected, ob_get_clean() ); + self::assertStringContainsString( '\n"; - $subject = new Form(); ob_start(); $subject->print_inline_styles(); - - self::assertSame( $expected, ob_get_clean() ); + $html = ob_get_clean(); + + self::assertStringStartsWith( '\n", $html ); } /** @@ -993,25 +1004,8 @@ static function ( $name ) { * @dataProvider dp_has_own_hcaptcha */ public function test_has_own_hcaptcha( bool $has ): void { - $form = new stdClass(); - - FunctionMocker::replace( - FormFieldsParser::class . '::resetData', - static function () { - // Do nothing. - } - ); - - FunctionMocker::replace( - FormFieldsParser::class . '::hasElement', - static function ( $a_form, $element ) use ( $form, $has ) { - if ( $form === $a_form && 'hcaptcha' === $element ) { - return $has; - } - - return ! $has; - } - ); + $fields = $has ? [ [ 'element' => 'hcaptcha' ] ] : []; + $form = $this->create_fluent_form( $fields ); $subject = Mockery::mock( Form::class )->makePartial(); @@ -1039,7 +1033,8 @@ public function dp_has_own_hcaptcha(): array { * @throws ReflectionException ReflectionException. */ public function test_get_hcaptcha(): void { - $form_id = 1; + $form = $this->create_fluent_form(); + $form_id = (int) $form->id; $args = [ 'action' => 'hcaptcha_fluentform', 'name' => 'hcaptcha_fluentform_nonce', @@ -1075,6 +1070,87 @@ public function test_get_hcaptcha_for_login_form(): void { self::assertSame( '', $subject->get_hcaptcha() ); } + /** + * Create a form through the live Fluent Forms ORM. + * + * @param array $fields Form fields. + * @param bool $multi_step Whether to make the form multi-step. + * + * @return FluentForm + */ + private function create_fluent_form( array $fields = [], bool $multi_step = false ): FluentForm { + $fields = $fields ?: [ + [ + 'element' => 'input_text', + 'attributes' => [ + 'type' => 'text', + 'name' => 'name', + 'value' => '', + 'id' => '', + 'class' => '', + 'placeholder' => 'Name', + ], + 'settings' => [ + 'container_class' => '', + 'label' => 'Name', + 'label_placement' => '', + 'help_message' => '', + 'validation_rules' => [ + 'required' => [ + 'value' => false, + 'message' => 'This field is required', + ], + ], + 'conditional_logics' => [], + ], + 'editor_options' => [ + 'title' => 'Simple Text', + 'template' => 'inputText', + ], + ], + ]; + $form_fields = [ + 'fields' => $fields, + 'submitButton' => [ + 'element' => 'button', + 'attributes' => [ + 'type' => 'submit', + 'class' => '', + ], + 'settings' => [ + 'align' => 'left', + 'button_style' => 'default', + 'container_class' => '', + 'help_message' => '', + 'button_size' => 'md', + 'button_ui' => [ + 'type' => 'default', + 'text' => 'Submit', + ], + ], + ], + ]; + + if ( $multi_step ) { + $form_fields['stepsWrapper'] = []; + } + + $form = FluentForm::create( + FluentForm::prepare( + [ + 'title' => 'hCaptcha integration form', + 'status' => 'published', + 'form_fields' => wp_json_encode( $form_fields ), + 'type' => 'form', + ] + ) + ); + + FormMeta::persist( $form->id, 'formSettings', FluentForm::getFormsDefaultSettings() ); + + return FluentForm::find( $form->id ); + } + /** * Get hCaptcha form wrapped. * diff --git a/tests/php/integration/Formidable/FormTest.php b/tests/php/integration/Formidable/FormTest.php index 05a7e0834..24d3bd402 100644 --- a/tests/php/integration/Formidable/FormTest.php +++ b/tests/php/integration/Formidable/FormTest.php @@ -15,8 +15,9 @@ use FrmSettings; use HCaptcha\FormidableForms\Form; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use Mockery; +use ReflectionClass; use ReflectionException; use stdClass; use tad\FunctionMocker\FunctionMocker; @@ -26,7 +27,69 @@ * * @group formidable */ -class FormTest extends HCaptchaWPTestCase { +class FormTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative path. + * + * @var string + */ + protected static $plugin = 'formidable/formidable.php'; + + /** + * Hooks to replay after loading Formidable Forms. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; + + /** + * Force lifecycle hook replay after WPTestCase resets action counters. + * + * @var bool + */ + protected static bool $force_plugin_load_hooks = true; + + /** + * Test a form created and rendered by the live Formidable Forms plugin. + */ + public function test_live_form_render(): void { + global $frm_settings, $wpdb; + + $settings = \FrmAppHelper::get_settings(); + $settings->active_captcha = 'hcaptcha'; + $settings->store(); + + $integration = new Form(); + $frm_settings = null; + + $form_id = $this->create_formidable_form(); + + $name_field = \FrmFieldsHelper::setup_new_vars( 'text', $form_id ); + $name_field['name'] = 'Name'; + + self::assertIsInt( \FrmField::create( $name_field ), $wpdb->last_error ); + + $captcha_field = \FrmFieldsHelper::setup_new_vars( 'captcha', $form_id ); + $captcha_field['name'] = 'Captcha'; + + self::assertIsInt( \FrmField::create( $captcha_field ), $wpdb->last_error ); + + $model_file = wp_normalize_path( ( new ReflectionClass( \FrmForm::class ) )->getFileName() ); + $html = do_shortcode( '[formidable id="' . $form_id . '"]' ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/formidable/' ), $model_file ); + self::assertTrue( shortcode_exists( 'formidable' ) ); + self::assertSame( 10, has_filter( 'frm_replace_shortcodes', [ $integration, 'add_hcaptcha' ] ) ); + self::assertStringContainsString( 'class="frm-show-form', $html ); + self::assertStringContainsString( 'type="text"', $html ); + self::assertStringContainsString( 'class="h-captcha"', $html ); + self::assertStringContainsString( 'hcaptcha_formidable_forms_nonce', $html ); + } /** * Tear down the test. @@ -34,6 +97,8 @@ class FormTest extends HCaptchaWPTestCase { * @return void */ public function tearDown(): void { + $GLOBALS['frm_settings'] = null; + unset( $GLOBALS['current_screen'] ); parent::tearDown(); @@ -127,17 +192,10 @@ public function test_add_hcaptcha(): void { $html = str_replace( $hcaptcha_div, '', $html_with_hcaptcha ); $field = [ 'type' => 'some' ]; $atts = [ 'form' => (object) [ 'id' => $form_id ] ]; - $frm_settings = new FrmSettings(); + $frm_settings = \FrmAppHelper::get_settings(); $frm_settings->active_captcha = 'recaptcha'; $expected = str_replace( $hcaptcha_div, $hcap_form, $html_with_hcaptcha ); - FunctionMocker::replace( - 'FrmAppHelper::get_settings', - static function () use ( &$frm_settings ) { - return $frm_settings; - } - ); - $subject = new Form(); // Field type is not captcha. @@ -170,16 +228,9 @@ public function test_prevent_native_validation(): void { 'type' => 'some', ]; $post = [ 'some past data' ]; - $frm_settings = new FrmSettings(); + $frm_settings = \FrmAppHelper::get_settings(); $frm_settings->active_captcha = 'recaptcha'; - FunctionMocker::replace( - 'FrmAppHelper::get_settings', - static function () use ( &$frm_settings ) { - return $frm_settings; - } - ); - $subject = Mockery::mock( Form::class )->makePartial(); $subject->shouldAllowMockingProtectedMethods(); @@ -202,10 +253,11 @@ static function () use ( &$frm_settings ) { * @return void */ public function test_verify_no_success(): void { + $form_id = $this->create_formidable_form(); $errors = [ 'some error' => 'some message' ]; $values = [ 'h-captcha-response' => 'some-token', - 'form_id' => 1, + 'form_id' => $form_id, 'item_meta' => [ 10 => 'John', 11 => 'Doe', @@ -227,11 +279,6 @@ public function test_verify_no_success(): void { FunctionMocker::replace( 'HCaptcha\Helpers\API::verify', $error_message ); - $form = new stdClass(); - $form->updated_at = $entry['form_date_gmt']; - - FunctionMocker::replace( [ 'FrmForm', 'getOne' ], $form ); - $subject = new Form(); self::assertSame( $expected, $subject->verify( $errors, $values, $validate_args ) ); @@ -257,7 +304,8 @@ protected function prepare_verify( string $nonce, string $action ): void { * @return void */ public function test_verify(): void { - $errors = [ 'some error' => 'some message' ]; + $form_id = $this->create_formidable_form(); + $errors = [ 'some error' => 'some message' ]; // Create field objects for posted_fields. $name_field = new stdClass(); @@ -283,7 +331,7 @@ public function test_verify(): void { $values = [ 'h-captcha-response' => 'some-token', - 'form_id' => 1, + 'form_id' => $form_id, 'item_meta' => [ 10 => [ 'John', 'Doe' ], 11 => 'john@doe.com', @@ -296,20 +344,11 @@ public function test_verify(): void { 'posted_fields' => [ $name_field, $email_field, $text_field, $empty_field ], ]; - $entry = [ - 'form_date_gmt' => '2023-01-01 10:00:00', - ]; - $this->prepare_verify( 'hcaptcha_formidable_forms_nonce', 'hcaptcha_formidable_forms' ); - $form = new stdClass(); - $form->updated_at = $entry['form_date_gmt']; - - FunctionMocker::replace( [ 'FrmForm', 'getOne' ], $form ); - $subject = new Form(); self::assertSame( $errors, $subject->verify( $errors, $values, $validate_args ) ); @@ -321,7 +360,7 @@ public function test_verify(): void { * @return void */ public function test_verify_missing_widget_id(): void { - $form_id = 5; + $form_id = $this->create_formidable_form(); $errors = []; $values = [ 'h-captcha-response' => 'some response', @@ -339,11 +378,6 @@ public function test_verify_missing_widget_id(): void { unset( $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] ); - $form = new stdClass(); - $form->created_at = '2023-01-01 10:00:00'; - - FunctionMocker::replace( [ 'FrmForm', 'getOne' ], $form ); - $subject = new Form(); self::assertSame( $expected, $subject->verify( $errors, $values, $validate_args ) ); @@ -458,4 +492,25 @@ public function test_is_formidable_forms_admin_page(): void { self::assertTrue( $subject->is_formidable_forms_admin_page() ); } } + + /** + * Create a form through the live Formidable Forms model. + * + * @return int + */ + private function create_formidable_form(): int { + \FrmAppController::install(); + + $form_id = \FrmForm::create( + [ + 'form_key' => uniqid( 'hcaptcha-form-', false ), + 'name' => 'hCaptcha integration form', + 'description' => '', + ] + ); + + self::assertIsInt( $form_id ); + + return $form_id; + } } diff --git a/tests/php/integration/Forminator/FormTest.php b/tests/php/integration/Forminator/FormTest.php index 73df3fe8e..cc497d4a1 100644 --- a/tests/php/integration/Forminator/FormTest.php +++ b/tests/php/integration/Forminator/FormTest.php @@ -13,10 +13,12 @@ namespace HCaptcha\Tests\Integration\Forminator; use Forminator_Front_Action; +use Forminator_API; use HCaptcha\Forminator\Form; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use Mockery; +use ReflectionClass; use ReflectionException; /** @@ -24,7 +26,58 @@ * * @group forminator */ -class FormTest extends HCaptchaWPTestCase { +class FormTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative path. + * + * @var string + */ + protected static $plugin = 'forminator/forminator.php'; + + /** + * Hooks to replay after loading the plugin. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; + + /** + * Test rendering through the live Forminator API and frontend renderer. + */ + public function test_live_form_render(): void { + $api_file = wp_normalize_path( ( new ReflectionClass( Forminator_API::class ) )->getFileName() ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/forminator/' ), $api_file ); + + $form_id = Forminator_API::add_form( 'hCaptcha integration form' ); + + self::assertIsInt( $form_id ); + + $field_id = Forminator_API::add_form_field( + $form_id, + 'text', + [ + 'field_label' => 'Name', + 'placeholder' => 'Name', + ] + ); + + self::assertIsString( $field_id ); + + new Form(); + + $html = forminator_form( $form_id ); + + self::assertStringContainsString( 'forminator-custom-form-' . $form_id, $html ); + self::assertStringContainsString( 'name="text-1"', $html ); + self::assertStringContainsString( 'class="h-captcha"', $html ); + self::assertStringContainsString( 'hcaptcha_forminator_nonce', $html ); + } /** * Tear down the test. diff --git a/tests/php/integration/GiveWP/BaseTest.php b/tests/php/integration/GiveWP/BaseTest.php index 6f8f278b5..40215ec16 100644 --- a/tests/php/integration/GiveWP/BaseTest.php +++ b/tests/php/integration/GiveWP/BaseTest.php @@ -14,11 +14,9 @@ use HCaptcha\GiveWP\Form; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use HCaptcha\Tests\Integration\Stubs\Give\DonationForms\ValueObjects\DonationFormErrorTypesStub; -use Mockery; -use tad\FunctionMocker\FunctionMocker; -use Give\DonationForms\ValueObjects\DonationFormErrorTypes; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; +use Give\Onboarding\DefaultFormFactory; +use ReflectionFunction; use WP_Error; /** @@ -26,7 +24,40 @@ * * @group givewp */ -class BaseTest extends HCaptchaWPTestCase { +class BaseTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative path. + * + * @var string + */ + protected static $plugin = 'give/give.php'; + + /** + * Hooks to replay after loading GiveWP. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; + + /** + * Force lifecycle hook replay after WPTestCase resets action counters. + * + * @var bool + */ + protected static bool $force_plugin_load_hooks = true; + + /** + * Expected late-bootstrap notice emitted by GiveWP's lifecycle container. + * + * @var string[] + */ + protected static array $plugin_expected_incorrect_usage = [ + '_lw_harbor_instance_registry', + ]; /** * Tear down the test. @@ -36,6 +67,13 @@ class BaseTest extends HCaptchaWPTestCase { public function tearDown(): void { unset( $_POST, $_GET, $_SERVER['REQUEST_METHOD'] ); + if ( function_exists( 'give_clear_errors' ) ) { + give_clear_errors(); + } + + wp_dequeue_script( 'hcaptcha-give-wp' ); + wp_deregister_script( 'hcaptcha-give-wp' ); + parent::tearDown(); } @@ -77,8 +115,8 @@ public function test_init_hooks_with_donation_form_view_route(): void { has_filter( 'hcap_print_hcaptcha_scripts', '__return_true' ) ); self::assertSame( - 9, - has_action( 'wp_print_footer_scripts', [ $subject, 'print_footer_scripts' ] ) + 10, + has_action( 'givewp_donation_form_enqueue_scripts', [ $subject, 'enqueue_scripts' ] ) ); self::assertSame( 10, @@ -98,7 +136,7 @@ public function test_init_hooks_wrong_route(): void { $subject = new Form(); self::assertFalse( - has_action( 'wp_print_footer_scripts', [ $subject, 'print_footer_scripts' ] ) + has_action( 'givewp_donation_form_enqueue_scripts', [ $subject, 'enqueue_scripts' ] ) ); } @@ -113,7 +151,7 @@ public function test_init_hooks_no_form_id(): void { $subject = new Form(); self::assertFalse( - has_action( 'wp_print_footer_scripts', [ $subject, 'print_footer_scripts' ] ) + has_action( 'givewp_donation_form_enqueue_scripts', [ $subject, 'enqueue_scripts' ] ) ); } @@ -133,6 +171,34 @@ public function test_add_captcha(): void { self::assertStringContainsString( 'h-captcha', $output ); } + /** + * Test hCaptcha in a donation form rendered by the live GiveWP shortcode. + * + * @return void + */ + public function test_live_donation_form(): void { + $form_id = ( new DefaultFormFactory() )->make(); + $user_id = self::factory()->user->create(); + + update_post_meta( $form_id, '_give_form_template', 'legacy' ); + wp_set_current_user( $user_id ); + + $subject = new Form(); + $function = new ReflectionFunction( 'give_form_shortcode' ); + $html = do_shortcode( '[give_form id="' . $form_id . '"]' ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( + wp_normalize_path( WP_PLUGIN_DIR . '/give/' ), + wp_normalize_path( (string) $function->getFileName() ) + ); + self::assertTrue( shortcode_exists( 'give_form' ) ); + self::assertStringContainsString( 'give-form', $html ); + self::assertStringContainsString( 'name="give-form-id"', $html ); + self::assertStringContainsString( 'name="hcaptcha_give_wp_form_nonce"', $html ); + self::assertSame( 10, has_action( 'give_donation_form_user_info', [ $subject, 'add_captcha' ] ) ); + } + /** * Test verify() with the correct action. * @@ -147,15 +213,13 @@ public function test_verify(): void { $_POST['action'] = 'give_process_donation'; $_POST['give-form-id'] = $form_id; - FunctionMocker::replace( 'give_set_error' ); + give_clear_errors(); $subject = new Form(); $subject->verify( true ); - // Verified successfully — give_set_error should not be called. - // No exception means success. - self::assertTrue( true ); + self::assertEmpty( give_get_errors() ); } /** @@ -172,23 +236,16 @@ public function test_verify_not_verified(): void { $_POST['action'] = 'give_process_donation'; $_POST['give-form-id'] = $form_id; - $error_slug = ''; - $error_message = ''; - - FunctionMocker::replace( - 'give_set_error', - static function ( $slug, $message ) use ( &$error_slug, &$error_message ) { - $error_slug = $slug; - $error_message = $message; - } - ); + give_clear_errors(); $subject = new Form(); $subject->verify( true ); - self::assertSame( 'invalid_hcaptcha', $error_slug ); - self::assertNotEmpty( $error_message ); + $errors = give_get_errors(); + + self::assertArrayHasKey( 'invalid_hcaptcha', $errors ); + self::assertNotEmpty( $errors['invalid_hcaptcha'] ); } /** @@ -219,22 +276,15 @@ static function () { $this->prepare_widget_id( $form_id ); - $error_slug = ''; - $error_message = ''; - - FunctionMocker::replace( - 'give_set_error', - static function ( $slug, $message ) use ( &$error_slug, &$error_message ) { - $error_slug = $slug; - $error_message = $message; - } - ); + give_clear_errors(); $subject = new Form(); $subject->verify( true ); - self::assertSame( 'invalid_hcaptcha', $error_slug ); + $errors = give_get_errors(); + $error_message = $errors['invalid_hcaptcha'] ?? ''; + self::assertStringContainsString( 'Please complete the hCaptcha.', $error_message ); self::assertStringNotContainsString( 'Token replayed or expired.', $error_message ); } @@ -246,20 +296,13 @@ static function ( $slug, $message ) use ( &$error_slug, &$error_message ) { public function test_verify_wrong_action(): void { $_POST['action'] = 'some_other_action'; - $called = false; - - FunctionMocker::replace( - 'give_set_error', - static function () use ( &$called ) { - $called = true; - } - ); + give_clear_errors(); $subject = new Form(); $subject->verify( true ); - self::assertFalse( $called ); + self::assertEmpty( give_get_errors() ); } /** @@ -362,8 +405,6 @@ public function test_verify_block_verified_with_form_id(): void { * @noinspection JsonEncodingApiUsageInspection */ public function test_verify_block_no_hcaptcha_response(): void { - Mockery::namedMock( DonationFormErrorTypes::class, DonationFormErrorTypesStub::class ); - $die_arr = []; $expected = [ '', @@ -411,8 +452,6 @@ static function () use ( &$die_arr ) { * @noinspection JsonEncodingApiUsageInspection */ public function test_verify_block_not_verified(): void { - Mockery::namedMock( DonationFormErrorTypes::class, DonationFormErrorTypes::class ); - $die_arr = []; $expected = [ '', @@ -452,17 +491,17 @@ static function () use ( &$die_arr ) { } /** - * Test print_footer_scripts(). + * Test enqueue_scripts(). * * @return void */ - public function test_print_footer_scripts(): void { + public function test_enqueue_scripts(): void { $_GET['givewp-route'] = 'donation-form-view'; $_GET['form-id'] = '42'; $subject = new Form(); - $subject->print_footer_scripts(); + $subject->enqueue_scripts(); $script = wp_scripts()->registered['hcaptcha-give-wp'] ?? null; @@ -470,6 +509,7 @@ public function test_print_footer_scripts(): void { self::assertStringContainsString( 'hcaptcha-givewp', $script->src ); self::assertContains( 'wp-blocks', $script->deps ); self::assertContains( 'hcaptcha', $script->deps ); + self::assertContains( 'hcaptcha-fst', $script->deps ); self::assertSame( HCAPTCHA_VERSION, $script->ver ); // Check localization. diff --git a/tests/php/integration/GravityForms/FieldTest.php b/tests/php/integration/GravityForms/FieldTest.php index 480364cb4..822e94887 100644 --- a/tests/php/integration/GravityForms/FieldTest.php +++ b/tests/php/integration/GravityForms/FieldTest.php @@ -12,17 +12,56 @@ namespace HCaptcha\Tests\Integration\GravityForms; -use HCaptcha\GravityForms\Base; +use GF_Field; +use GFAPI; use HCaptcha\GravityForms\Field; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use tad\FunctionMocker\FunctionMocker; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; /** * Test GravityForms Field class. * * @group gravityforms */ -class FieldTest extends HCaptchaWPTestCase { +class FieldTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative path. + * + * @var string + */ + protected static $plugin = 'gravityforms/gravityforms.php'; + + /** + * Hooks to replay after loading the plugin. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + ]; + + /** + * Whether the live Gravity Forms schema was prepared. + * + * @var bool + */ + private static bool $schema_ready = false; + + /** + * Set up the live Gravity Forms database schema. + */ + public function setUp(): void { + parent::setUp(); + + // The WP test transaction rolls these options back after every test. + update_option( 'gf_db_version', \GFForms::$version, false ); + update_option( 'rg_form_version', \GFForms::$version, false ); + + if ( ! self::$schema_ready ) { + gf_upgrade()->upgrade_schema(); + self::$schema_ready = true; + } + } /** * Tear down the test. @@ -30,7 +69,7 @@ class FieldTest extends HCaptchaWPTestCase { * @return void */ public function tearDown(): void { - unset( $_POST['action'], $_POST['field'], $_GET['id'] ); + unset( $_POST['action'], $_POST['field'], $_GET['id'], $_REQUEST['id'] ); parent::tearDown(); } @@ -217,32 +256,18 @@ public function test_get_form_editor_field_settings(): void { * @return void */ public function test_get_field_input(): void { - $form_id = 23; - $field_id = 'input_0'; - $tabindex = 0; - $form = [ + $form_id = 23; + $form = [ 'id' => $form_id, ]; - $value = ''; - $entry = null; - $args = [ - 'action' => Base::ACTION, - 'name' => Base::NONCE, - 'id' => [ - 'source' => [ 'gravityforms/gravityforms.php' ], - 'form_id' => $form_id, - ], - ]; - $search = 'class="h-captcha"'; - $expected = str_replace( - $search, - $search . ' id="' . $field_id . '" data-tabindex="' . $tabindex . '"', - $this->get_hcap_form( $args ) - ); $subject = new Field(); + $output = $subject->get_field_input( $form ); - self::assertSame( $expected, $subject->get_field_input( $form, $value, $entry ) ); + self::assertStringContainsString( 'class="h-captcha"', $output ); + self::assertStringContainsString( 'id="input_' . $form_id . '_0"', $output ); + self::assertStringContainsString( 'data-tabindex="', $output ); + self::assertStringContainsString( 'gravity_forms_nonce', $output ); } /** @@ -253,16 +278,6 @@ public function test_get_field_input(): void { public function test_disable_duplication(): void { $duplicate_field_link = '#some-link'; $field_id = 55; - $field = (object) []; - - FunctionMocker::replace( - 'rgpost', - static function ( $name ) { - // phpcs:ignore - return $_POST[ $name ] ?? ''; - } - ); - FunctionMocker::replace( 'GFFormsModel::get_field', $field ); $subject = new Field(); @@ -289,14 +304,37 @@ static function ( $name ) { // Action is not rg_add_field, proper link, no field type. $duplicate_field_link = "some text"; + $field = new GF_Field(); + $field->id = $field_id; + $field->type = 'text'; + $form_id = GFAPI::add_form( + [ + 'title' => 'Duplication test form', + 'fields' => [ $field ], + ] + ); + + self::assertIsInt( $form_id ); - $_GET['id'] = 5; + $_GET['id'] = $form_id; + $_REQUEST['id'] = $form_id; self::assertSame( $duplicate_field_link, $subject->disable_duplication( $duplicate_field_link ) ); // Action is not rg_add_field, proper link, field type is hcaptcha. - $field = (object) [ 'type' => 'hcaptcha' ]; - FunctionMocker::replace( 'GFFormsModel::get_field', $field ); + $hcaptcha_field = new Field(); + $hcaptcha_field->id = $field_id; + $form_id = GFAPI::add_form( + [ + 'title' => 'hCaptcha duplication test form', + 'fields' => [ $hcaptcha_field ], + ] + ); + + self::assertIsInt( $form_id ); + + $_GET['id'] = $form_id; + $_REQUEST['id'] = $form_id; self::assertSame( '', $subject->disable_duplication( $duplicate_field_link ) ); } diff --git a/tests/php/integration/GravityForms/FormTest.php b/tests/php/integration/GravityForms/FormTest.php index 1e29864de..6b43ee550 100644 --- a/tests/php/integration/GravityForms/FormTest.php +++ b/tests/php/integration/GravityForms/FormTest.php @@ -13,20 +13,100 @@ namespace HCaptcha\Tests\Integration\GravityForms; use GF_Field; +use GFAPI; use HCaptcha\GravityForms\Base; use HCaptcha\GravityForms\Form; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use Mockery; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; +use ReflectionClass; use ReflectionException; -use tad\FunctionMocker\FunctionMocker; /** * Test GravityForms Form class. * * @group gravityforms */ -class FormTest extends HCaptchaWPTestCase { +class FormTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative path. + * + * @var string + */ + protected static $plugin = 'gravityforms/gravityforms.php'; + + /** + * Hooks to replay after loading the plugin. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + ]; + + /** + * Whether the live Gravity Forms schema was prepared. + * + * @var bool + */ + private static bool $schema_ready = false; + + /** + * Set up the live Gravity Forms database schema. + */ + public function setUp(): void { + parent::setUp(); + + // The WP test transaction rolls these options back after every test. + update_option( 'gf_db_version', \GFForms::$version, false ); + update_option( 'rg_form_version', \GFForms::$version, false ); + + if ( ! self::$schema_ready ) { + gf_upgrade()->upgrade_schema(); + self::$schema_ready = true; + } + } + + /** + * Test rendering through the live Gravity Forms API and frontend renderer. + */ + public function test_live_form_render(): void { + $api_file = wp_normalize_path( ( new ReflectionClass( GFAPI::class ) )->getFileName() ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/gravityforms/' ), $api_file ); + + $form_id = GFAPI::add_form( + [ + 'title' => 'hCaptcha integration form', + 'fields' => [ + [ + 'id' => 1, + 'type' => 'text', + 'label' => 'Name', + 'isRequired' => true, + ], + ], + 'button' => [ + 'type' => 'text', + 'text' => 'Submit', + ], + ] + ); + + self::assertIsInt( $form_id ); + + update_option( 'hcaptcha_settings', [ 'gravity_status' => [ 'form' ] ] ); + hcaptcha()->init_hooks(); + new Form(); + + $html = gravity_form( $form_id, false, false, false, null, false, 0, false ); + + self::assertStringContainsString( 'gform_wrapper', $html ); + self::assertStringContainsString( "name='input_1'", $html ); + self::assertStringContainsString( 'class="h-captcha"', $html ); + self::assertStringContainsString( 'gravity_forms_nonce', $html ); + } /** * Tear down the test. @@ -149,49 +229,44 @@ public function dp_test_add_hcaptcha(): array { * @throws ReflectionException ReflectionException. */ public function test_add_hcaptcha_in_embed_mode(): void { - $button_input = ''; - $hcaptcha_field = (object) [ - 'type' => 'hcaptcha', - ]; - $form_id = 23; - $form = [ - 'id' => $form_id, - 'fields' => [], - ]; - $expected = $this->get_hcap_form( - [ - 'action' => Base::ACTION, - 'name' => Base::NONCE, - 'id' => [ - 'source' => [ 'gravityforms/gravityforms.php' ], - 'form_id' => $form_id, - ], - ] - ); + $button_input = ''; update_option( 'hcaptcha_settings', [ 'gravity_status' => [ 'embed' ] ] ); hcaptcha()->init_hooks(); $subject = new Form(); + $form_id = 999999; + $form = [ + 'id' => $form_id, + 'fields' => [], + ]; $this->set_protected_property( $subject, 'form_id', $form_id ); - add_filter( 'hcap_form_args', [ $subject, 'hcap_form_args' ] ); // Form does not exist (strange case), add hCaptcha. - FunctionMocker::replace( 'GFFormsModel::get_form_meta' ); - - self::assertSame( $expected, $subject->add_hcaptcha( $button_input, $form ) ); + self::assertSame( $this->get_gravity_hcaptcha( $form_id ), $subject->add_hcaptcha( $button_input, $form ) ); // Does not have hCaptcha in the form, add hCaptcha. - FunctionMocker::replace( 'GFFormsModel::get_form_meta', $form ); + $form = $this->create_gravity_form( [] ); + $form_id = (int) $form['id']; - self::assertSame( $expected, $subject->add_hcaptcha( $button_input, $form ) ); + $this->set_protected_property( $subject, 'form_id', $form_id ); - // Has hCaptcha in the form, do not add hCaptcha. - $form['fields'] = [ $hcaptcha_field ]; + self::assertSame( $this->get_gravity_hcaptcha( $form_id ), $subject->add_hcaptcha( $button_input, $form ) ); - FunctionMocker::replace( 'GFFormsModel::get_form_meta', $form ); + // Has hCaptcha in the form, do not add hCaptcha. + $form = $this->create_gravity_form( + [ + $this->get_gf_field( + [ + 'id' => 1, + 'type' => 'hcaptcha', + 'label' => 'hCaptcha', + ] + ), + ] + ); self::assertSame( $button_input, $subject->add_hcaptcha( $button_input, $form ) ); } @@ -243,42 +318,8 @@ public function test_gform_close(): void { * @dataProvider dp_test_verify */ public function test_verify( string $mode ): void { - $form_id = 23; - $form = [ - 'id' => $form_id, - 'fields' => [ - $this->get_gf_field( - [ - 'id' => 3, - 'type' => 'name', - 'label' => 'Name', - 'inputs' => [ - [ 'id' => '3.2' ], - [ 'id' => '3.3' ], - [ 'id' => '3.4' ], - [ 'id' => '3.6' ], - [ 'id' => '3.8' ], - ], - ] - ), - $this->get_gf_field( - [ - 'id' => 4, - 'type' => 'email', - 'label' => 'Email', - 'inputs' => null, - ] - ), - $this->get_gf_field( - [ - 'id' => 2, - 'type' => 'hcaptcha', - 'label' => 'hCaptcha', - 'inputs' => null, - ] - ), - ], - ]; + $form = $this->create_verification_form(); + $form_id = (int) $form['id']; $validation_result = [ 'is_valid' => true, 'form' => $form, @@ -291,8 +332,6 @@ public function test_verify( string $mode ): void { $_POST['input_4'] = 'foo@bar.com'; $_POST['gform_submit'] = $form_id; - FunctionMocker::replace( 'GFFormsModel::get_form_meta', $form ); - update_option( 'hcaptcha_settings', [ 'gravity_status' => [ $mode ] ] ); $this->prepare_verify_post( Base::NONCE, Base::ACTION ); @@ -314,42 +353,8 @@ public function test_verify( string $mode ): void { * @dataProvider dp_test_verify */ public function test_verify_not_verified( string $mode ): void { - $form_id = 23; - $form = [ - 'id' => $form_id, - 'fields' => [ - $this->get_gf_field( - [ - 'id' => 3, - 'type' => 'name', - 'label' => 'Name', - 'inputs' => [ - [ 'id' => '3.2' ], - [ 'id' => '3.3' ], - [ 'id' => '3.4' ], - [ 'id' => '3.6' ], - [ 'id' => '3.8' ], - ], - ] - ), - $this->get_gf_field( - [ - 'id' => 4, - 'type' => 'email', - 'label' => 'Email', - 'inputs' => null, - ] - ), - $this->get_gf_field( - [ - 'id' => 2, - 'type' => 'hcaptcha', - 'label' => 'hCaptcha', - 'inputs' => null, - ] - ), - ], - ]; + $form = $this->create_verification_form(); + $form_id = (int) $form['id']; $validation_result = [ 'is_valid' => true, 'form' => $form, @@ -367,8 +372,6 @@ public function test_verify_not_verified( string $mode ): void { $_POST['input_4'] = 'foo@bar.com'; $_POST['gform_submit'] = $form_id; - FunctionMocker::replace( 'GFFormsModel::get_form_meta', $form ); - $this->prepare_verify_post( Base::NONCE, Base::ACTION, false ); $this->prepare_widget_id( $form_id ); @@ -386,34 +389,8 @@ public function test_verify_not_verified( string $mode ): void { * @return void */ public function test_verify_missing_widget_id(): void { - $form_id = 23; - $form = [ - 'id' => $form_id, - 'fields' => [ - $this->get_gf_field( - [ - 'id' => 3, - 'type' => 'name', - 'label' => 'Name', - 'inputs' => [ - [ 'id' => '3.2' ], - [ 'id' => '3.3' ], - [ 'id' => '3.4' ], - [ 'id' => '3.6' ], - [ 'id' => '3.8' ], - ], - ] - ), - $this->get_gf_field( - [ - 'id' => 4, - 'type' => 'email', - 'label' => 'Email', - 'inputs' => null, - ] - ), - ], - ]; + $form = $this->create_verification_form( false ); + $form_id = (int) $form['id']; $validation_result = [ 'is_valid' => true, 'form' => $form, @@ -431,8 +408,6 @@ public function test_verify_missing_widget_id(): void { $_POST['input_4'] = 'foo@bar.com'; $_POST['gform_submit'] = $form_id; - FunctionMocker::replace( 'GFFormsModel::get_form_meta', $form ); - $this->prepare_verify_post( Base::NONCE, Base::ACTION ); update_option( 'hcaptcha_settings', [ 'gravity_status' => [ 'form' ] ] ); @@ -494,98 +469,53 @@ public function dp_test_verify(): array { * @return void */ public function test_verify_when_should_not_be_verified(): void { - $form_id = 2; - $nested_form_id = 9; - $multipage_form_id = 3; - $source_page_name = "gform_source_page_number_$multipage_form_id"; - $target_page_name = "gform_target_page_number_$multipage_form_id"; - $hcaptcha_field = (object) [ - 'type' => 'hcaptcha', - ]; - $nested_form_field = Mockery::mock( 'GP_Field_Nested_Form' ); - // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $nested_form_field->gpnfForm = $form_id; - $form_fields = [ - 'id' => $form_id, - 'fields' => [ $hcaptcha_field ], - ]; - $nested_form_fields = [ - 'id' => $nested_form_id, - 'fields' => [ $nested_form_field ], - ]; - $multipage_form_fields = [ - 'id' => $nested_form_id, - 'pagination' => [ - 'pages' => [ - 0 => [], - 1 => [], + $form = $this->create_gravity_form( + [], + [ + 'pagination' => [ + 'pages' => [ + [ 'name' => 'First page' ], + [ 'name' => 'Second page' ], + ], ], - ], - 'fields' => [ $hcaptcha_field ], - ]; - $validation_result = [ + ] + ); + $form_id = (int) $form['id']; + $source_page_name = "gform_source_page_number_$form_id"; + $target_page_name = "gform_target_page_number_$form_id"; + $validation_result = [ 'is_valid' => true, - 'form' => [], + 'form' => $form, 'failed_validation_page' => 0, ]; - $context = 'form-submit'; + $context = 'form-submit'; + + update_option( 'hcaptcha_settings', [ 'gravity_status' => [ 'form' ] ] ); + hcaptcha()->init_hooks(); $subject = new Form(); // The POST 'gform_submit' not set. self::assertSame( $validation_result, $subject->verify( $validation_result, $context ) ); - // Nested form. - $_POST['gform_submit'] = $form_id; - $_POST['gpnf_parent_form_id'] = $nested_form_id; - - FunctionMocker::replace( - 'GFFormsModel::get_form_meta', - static function ( $id ) use ( - $form_id, - $form_fields, - $nested_form_id, - $nested_form_fields, - $multipage_form_id, - $multipage_form_fields - ) { - if ( $id === $form_id ) { - return $form_fields; - } - - if ( $id === $nested_form_id ) { - return $nested_form_fields; - } - - if ( $id === $multipage_form_id ) { - return $multipage_form_fields; - } - - return []; - } - ); - - self::assertSame( $validation_result, $subject->verify( $validation_result, $context ) ); - - // Not a nested form. - unset( $_POST['gpnf_parent_form_id'] ); - // Multipage form. - $_POST['gform_submit'] = $multipage_form_id; + $_POST['gform_submit'] = $form_id; - // The POST target_page is set and not 0. + // Switching pages does not verify hCaptcha. $_POST[ $source_page_name ] = 1; $_POST[ $target_page_name ] = 2; self::assertSame( $validation_result, $subject->verify( $validation_result, $context ) ); - // The POST target_page is set and 0. + // Submitting the last page verifies hCaptcha through the live form metadata. $_POST[ $source_page_name ] = 2; $_POST[ $target_page_name ] = 0; + $this->prepare_verify_post( Base::NONCE, Base::ACTION ); + $this->prepare_widget_id( $form_id ); self::assertSame( $validation_result, $subject->verify( $validation_result, $context ) ); - // The POST target_page is unset. + // An unset target page is also a final submission. unset( $_POST[ $target_page_name ] ); self::assertSame( $validation_result, $subject->verify( $validation_result, $context ) ); @@ -646,63 +576,18 @@ public function test_form_validation_errors_markup(): void { * @noinspection CssUnusedSymbol */ public function test_print_inline_styles(): void { - FunctionMocker::replace( - 'defined', - static function ( $constant_name ) { - return 'SCRIPT_DEBUG' === $constant_name; - } - ); - - FunctionMocker::replace( - 'constant', - static function ( $name ) { - return 'SCRIPT_DEBUG' === $name; - } - ); - - $expected = <<<'CSS' - .gform_previous_button + .h-captcha { - margin-top: 2rem; - } - - .gform_footer.before .h-captcha[data-size="normal"] { - margin-bottom: 3px; - } - - .gform_footer.before .h-captcha[data-size="compact"] { - margin-bottom: 0; - } - - .gform_wrapper.gravity-theme .gform_footer, - .gform_wrapper.gravity-theme .gform_page_footer { - flex-wrap: wrap; - } - - .gform_wrapper.gravity-theme .h-captcha, - .gform_wrapper.gravity-theme .h-captcha { - margin: 0; - flex-basis: 100%; - } - - .gform_wrapper.gravity-theme input[type="submit"], - .gform_wrapper.gravity-theme input[type="submit"] { - align-self: flex-start; - } - - .gform_wrapper.gravity-theme .h-captcha ~ input[type="submit"], - .gform_wrapper.gravity-theme .h-captcha ~ input[type="submit"] { - margin: 1em 0 0 0 !important; - } -CSS; - $expected = "\n"; - $subject = new Form(); ob_start(); $subject->print_inline_styles(); - self::assertSame( $expected, ob_get_clean() ); + $output = ob_get_clean(); + + self::assertStringStartsWith( '\n", $output ); } /** @@ -747,6 +632,101 @@ private function prepare_widget_id( int $form_id, array $id = [] ): void { $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); } + /** + * Create and reload a form through the live Gravity Forms API. + * + * @param array $fields Form fields. + * @param array $properties Additional form properties. + * + * @return array + */ + private function create_gravity_form( array $fields, array $properties = [] ): array { + $form_id = GFAPI::add_form( + array_merge( + [ + 'title' => 'hCaptcha integration form', + 'fields' => $fields, + ], + $properties + ) + ); + + self::assertIsInt( $form_id ); + + $form = GFAPI::get_form( $form_id ); + + self::assertIsArray( $form ); + + return $form; + } + + /** + * Get the expected Gravity Forms hCaptcha markup. + * + * @param int $form_id Form ID. + * + * @return string + */ + private function get_gravity_hcaptcha( int $form_id ): string { + return $this->get_hcap_form( + [ + 'action' => Base::ACTION, + 'name' => Base::NONCE, + 'id' => [ + 'source' => [ 'gravityforms/gravityforms.php' ], + 'form_id' => $form_id, + ], + ] + ); + } + + /** + * Create a Gravity Forms form used by verification tests. + * + * @param bool $with_hcaptcha Whether to add an embedded hCaptcha field. + * + * @return array + */ + private function create_verification_form( bool $with_hcaptcha = true ): array { + $fields = [ + $this->get_gf_field( + [ + 'id' => 3, + 'type' => 'name', + 'label' => 'Name', + 'inputs' => [ + [ 'id' => '3.2' ], + [ 'id' => '3.3' ], + [ 'id' => '3.4' ], + [ 'id' => '3.6' ], + [ 'id' => '3.8' ], + ], + ] + ), + $this->get_gf_field( + [ + 'id' => 4, + 'type' => 'email', + 'label' => 'Email', + 'inputs' => null, + ] + ), + ]; + + if ( $with_hcaptcha ) { + $fields[] = $this->get_gf_field( + [ + 'id' => 2, + 'type' => 'hcaptcha', + 'label' => 'hCaptcha', + 'inputs' => null, + ] + ); + } + + return $this->create_gravity_form( $fields ); + } + /** * Get GF_Field object. * diff --git a/tests/php/integration/HCaptchaPluginWPTestCase.php b/tests/php/integration/HCaptchaPluginWPTestCase.php index 85b438497..fadd2f268 100644 --- a/tests/php/integration/HCaptchaPluginWPTestCase.php +++ b/tests/php/integration/HCaptchaPluginWPTestCase.php @@ -12,62 +12,517 @@ namespace HCaptcha\Tests\Integration; +use WP_Theme; + /** * Class HCaptchaPluginWPTestCase */ class HCaptchaPluginWPTestCase extends HCaptchaWPTestCase { /** - * Plugin relative path. + * Plugin relative path or paths. * - * @var string + * @var string|string[] */ protected static $plugin; /** - * Plugin active status. + * Theme stylesheet slug. + * + * @var string + */ + protected static string $theme = ''; + + /** + * Plugins whose PHP entry files have been loaded in this process. + * + * @var array + */ + protected static array $plugin_loaded = []; + + /** + * Hooks registered by loaded plugins. + * + * @var array>>> + */ + protected static array $plugin_hooks = []; + + /** + * Shortcodes registered by loaded plugins. + * + * @var array> + */ + protected static array $plugin_shortcodes = []; + + /** + * Themes whose PHP files have been loaded in this process. + * + * @var array + */ + protected static array $theme_loaded = []; + + /** + * Theme shortcode tags and the classes that register them. + * + * @var array + */ + protected static array $theme_shortcode_classes = []; + + /** + * Shortcodes registered by loaded themes. + * + * @var array> + */ + protected static array $theme_shortcodes = []; + + /** + * Hooks to replay when plugins are loaded after the hooks fired. + * + * @var string[] + */ + protected static array $plugin_load_hooks = []; + + /** + * Whether plugin lifecycle callbacks must be replayed even when WPTestCase reset their action counters. + * + * @var bool + */ + protected static bool $force_plugin_load_hooks = false; + + /** + * Expected incorrect usage notices caused by loading plugins after WordPress bootstrap. + * + * @var string[] + */ + protected static array $plugin_expected_incorrect_usage = []; + + /** + * Expected deprecation notices caused by loading plugins after WordPress bootstrap. + * + * @var string[] + */ + protected static array $plugin_expected_deprecated = []; + + /** + * Activation error codes allowed for individual test plugins. + * + * @var array + */ + protected static array $plugin_allowed_activation_errors = []; + + /** + * Plugins whose activation hooks must be skipped in the test process. + * + * @var string[] + */ + protected static array $plugin_silent_activation = []; + + /** + * Expected incorrect usage notices caused by loading a theme after WordPress bootstrap. * - * @var array + * @var string[] */ - protected static $plugin_active = []; + protected static array $theme_expected_incorrect_usage = []; /** * Teardown after class. */ public static function tearDownAfterClass(): void { - deactivate_plugins( static::$plugin ); + $plugins = array_reverse( (array) static::$plugin ); + + deactivate_plugins( $plugins ); parent::tearDownAfterClass(); } + // phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh /** * Setup test. */ public function setUp(): void { + // phpcs:enable Generic.Metrics.CyclomaticComplexity.TooHigh $plugins_requiring_php = [ '7.4' => [ 'contact-form-7/wp-contact-form-7.php', + 'elementor/elementor.php', + 'elementor-pro/elementor-pro.php', + 'mailchimp-for-wp/mailchimp-for-wp.php', 'ninja-forms/ninja-forms.php', 'woocommerce/woocommerce.php', ], ]; - foreach ( $plugins_requiring_php as $php_version => $plugins_requiring_php_version ) { - if ( - in_array( static::$plugin, $plugins_requiring_php_version, true ) && - version_compare( PHP_VERSION, $php_version, '<' ) - ) { - self::markTestSkipped( - 'This test requires PHP ' . $php_version . ' at least.' - ); + foreach ( (array) static::$plugin as $plugin ) { + foreach ( $plugins_requiring_php as $php_version => $plugins_requiring_php_version ) { + if ( + in_array( $plugin, $plugins_requiring_php_version, true ) && + version_compare( PHP_VERSION, $php_version, '<' ) + ) { + self::markTestSkipped( + 'This test requires PHP ' . $php_version . ' at least.' + ); + } } } parent::setUp(); - if ( static::$plugin && ! isset( static::$plugin_active[ static::$plugin ] ) ) { - activate_plugin( static::$plugin ); - static::$plugin_active[ static::$plugin ] = true; + $this->load_test_theme(); + $this->before_load_test_plugins(); + + $hook_callbacks = []; + $plugin_key = implode( '|', (array) static::$plugin ); + $previous_shortcodes = $GLOBALS['shortcode_tags'] ?? []; + + $this->load_test_plugin_hooks( $plugin_key ); + + $previous_plugin_hooks = $this->get_all_hook_callbacks(); + + foreach ( static::$plugin_load_hooks as $hook_name ) { + $hook_callbacks[ $hook_name ] = $this->get_hook_callbacks( $hook_name ); + } + + foreach ( static::$plugin_expected_incorrect_usage as $incorrect_usage ) { + $this->setExpectedIncorrectUsage( $incorrect_usage ); + } + + foreach ( static::$plugin_expected_deprecated as $deprecated ) { + $this->setExpectedDeprecated( $deprecated ); + } + + foreach ( (array) static::$plugin as $plugin ) { + $this->activate_test_plugin( $plugin ); + $this->replay_elementor_loaded_action( $plugin ); + } + + foreach ( $hook_callbacks as $hook_name => $previous_callbacks ) { + if ( ! static::$force_plugin_load_hooks ) { + $this->run_late_hook_callbacks( $hook_name, $previous_callbacks ); + + continue; + } + + for ( $pass = 0; $pass < 10; ++$pass ) { + $current_callbacks = $this->run_late_hook_callbacks( $hook_name, $previous_callbacks, true ); + + if ( $current_callbacks === $previous_callbacks ) { + break; + } + + $previous_callbacks = $current_callbacks; + } + } + + $this->store_test_plugin_hooks( $plugin_key, $previous_plugin_hooks ); + $this->load_test_plugin_shortcodes( $plugin_key, $previous_shortcodes ); + } + + /** + * Prepare options and hooks required before test plugins are loaded. + * + * @return void + */ + protected function before_load_test_plugins(): void { + } + + /** + * Restore callbacks registered by test plugins. + * + * @param string $plugin_key Plugin entry files identifying the test group. + * + * @return void + */ + private function load_test_plugin_hooks( string $plugin_key ): void { + foreach ( static::$plugin_hooks[ $plugin_key ] ?? [] as $hook_name => $priorities ) { + foreach ( $priorities as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + add_filter( $hook_name, $callback['function'], $priority, $callback['accepted_args'] ); + } + } + } + } + + /** + * Store callbacks registered while test plugins were activated. + * + * @param string $plugin_key Plugin entry files identifying the test group. + * @param array $previous_hooks Hooks registered before plugin activation. + * + * @return void + */ + private function store_test_plugin_hooks( string $plugin_key, array $previous_hooks ): void { + if ( ! $plugin_key ) { + return; } + + foreach ( $this->get_all_hook_callbacks() as $hook_name => $priorities ) { + foreach ( $priorities as $priority => $callbacks ) { + foreach ( $callbacks as $callback_id => $callback ) { + if ( isset( $previous_hooks[ $hook_name ][ $priority ][ $callback_id ] ) ) { + continue; + } + + static::$plugin_hooks[ $plugin_key ][ $hook_name ][ $priority ][ $callback_id ] = $callback; + } + } + } + } + + /** + * Store and restore shortcode callbacks registered by test plugins. + * + * @param string $plugin_key Plugin entry files identifying the test group. + * @param array $previous_shortcodes Shortcodes registered before plugin activation. + * + * @return void + */ + private function load_test_plugin_shortcodes( string $plugin_key, array $previous_shortcodes ): void { + if ( ! $plugin_key ) { + return; + } + + $new_shortcodes = array_diff_key( $GLOBALS['shortcode_tags'] ?? [], $previous_shortcodes ); + + static::$plugin_shortcodes[ $plugin_key ] = array_merge( + static::$plugin_shortcodes[ $plugin_key ] ?? [], + $new_shortcodes + ); + + foreach ( static::$plugin_shortcodes[ $plugin_key ] as $tag => $callback ) { + add_shortcode( $tag, $callback ); + } + } + + /** + * Load and activate a theme for the current test. + * + * @return void + */ + private function load_test_theme(): void { + if ( ! static::$theme ) { + return; + } + + $theme = wp_get_theme( static::$theme ); + + if ( ! $theme->exists() || $theme->errors() ) { + self::markTestSkipped( 'The ' . static::$theme . ' theme is not installed.' ); + } + + $this->set_active_test_theme( $theme ); + + if ( isset( static::$theme_loaded[ static::$theme ] ) ) { + $this->load_test_theme_shortcodes(); + return; + } + + $this->initialize_test_theme( $theme ); + $this->load_test_theme_shortcodes(); + + static::$theme_loaded[ static::$theme ] = true; + } + + /** + * Load theme files and replay its WordPress lifecycle callbacks. + * + * @param WP_Theme $theme Theme instance. + * + * @return void + */ + private function initialize_test_theme( WP_Theme $theme ): void { + $hook_callbacks = []; + $previous_shortcodes = $GLOBALS['shortcode_tags'] ?? []; + + foreach ( [ 'after_setup_theme', 'init', 'wp_loaded' ] as $hook_name ) { + $hook_callbacks[ $hook_name ] = $this->get_hook_callbacks( $hook_name ); + } + + foreach ( static::$theme_expected_incorrect_usage as $incorrect_usage ) { + $this->setExpectedIncorrectUsage( $incorrect_usage ); + } + + require_once $theme->get_template_directory() . '/functions.php'; + + foreach ( $hook_callbacks as $hook_name => $previous_callbacks ) { + for ( $pass = 0; $pass < 10; ++$pass ) { + $current_callbacks = $this->run_late_hook_callbacks( $hook_name, $previous_callbacks, true ); + + if ( $current_callbacks === $previous_callbacks ) { + break; + } + + $previous_callbacks = $current_callbacks; + } + } + + static::$theme_shortcodes[ static::$theme ] = array_diff_key( + $GLOBALS['shortcode_tags'] ?? [], + $previous_shortcodes + ); + } + + /** + * Load lazy theme modules and restore their shortcode callbacks. + * + * @return void + */ + private function load_test_theme_shortcodes(): void { + foreach ( static::$theme_shortcodes[ static::$theme ] ?? [] as $tag => $callback ) { + add_shortcode( $tag, $callback ); + } + + foreach ( static::$theme_shortcode_classes as $class_name ) { + if ( class_exists( $class_name ) ) { + new $class_name(); + } + } + } + + /** + * Make a theme active for the current test. + * + * @param WP_Theme $theme Theme instance. + * + * @return void + */ + private function set_active_test_theme( WP_Theme $theme ): void { + $template = $theme->get_template(); + $stylesheet = $theme->get_stylesheet(); + + add_filter( + 'pre_option_template', + static function () use ( $template ) { + return $template; + }, + PHP_INT_MIN + ); + add_filter( + 'pre_option_stylesheet', + static function () use ( $stylesheet ) { + return $stylesheet; + }, + PHP_INT_MIN + ); + + $GLOBALS['wp_template_path'] = $theme->get_template_directory(); + $GLOBALS['wp_stylesheet_path'] = $theme->get_stylesheet_directory(); + } + + /** + * Get callbacks currently registered on a hook. + * + * @param string $hook_name Hook name. + * + * @return array + */ + private function get_hook_callbacks( string $hook_name ): array { + return $GLOBALS['wp_filter'][ $hook_name ]->callbacks ?? []; + } + + /** + * Get callbacks currently registered on all hooks. + * + * @return array + */ + private function get_all_hook_callbacks(): array { + + return array_map( + static function ( $hook ) { + return $hook->callbacks; + }, + $GLOBALS['wp_filter'] + ); + } + + /** + * Activate a plugin for the current test. + * + * @param string $plugin Plugin relative path. + * + * @return void + */ + private function activate_test_plugin( string $plugin ): void { + if ( ! $plugin || is_plugin_active( $plugin ) ) { + return; + } + + $silent = isset( static::$plugin_loaded[ $plugin ] ) || in_array( + $plugin, + static::$plugin_silent_activation, + true + ); + $result = activate_plugin( $plugin, '', false, $silent ); + + if ( + is_wp_error( $result ) && + ! in_array( $result->get_error_code(), static::$plugin_allowed_activation_errors[ $plugin ] ?? [], true ) + ) { + $output = trim( (string) $result->get_error_data() ); + $message = $result->get_error_message(); + + self::fail( $output ? $message . ' ' . $output : $message ); + } + + static::$plugin_loaded[ $plugin ] = true; + } + + /** + * Replay the Elementor loaded action when the test runner reset its state. + * + * @param string $plugin Plugin relative path. + * + * @return void + */ + private function replay_elementor_loaded_action( string $plugin ): void { + if ( + 'elementor/elementor.php' !== $plugin || + ! class_exists( '\\Elementor\\Plugin', false ) || + did_action( 'elementor/loaded' ) + ) { + return; + } + + // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + do_action( 'elementor/loaded' ); + } + + /** + * Run callbacks registered by a plugin after the hook fired. + * + * @param string $hook_name Hook name. + * @param array $previous_callbacks Callbacks registered before plugin activation. + * @param bool $force Run callbacks even when the hook counter was reset by the test runner. + * + * @return array + */ + private function run_late_hook_callbacks( string $hook_name, array $previous_callbacks, bool $force = false ): array { + if ( ! $force && ! did_action( $hook_name ) ) { + return $previous_callbacks; + } + + if ( $force && ! did_action( $hook_name ) ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Preserve the lifecycle state reset by WPTestCase. + $GLOBALS['wp_actions'][ $hook_name ] = 1; + } + + foreach ( $this->get_hook_callbacks( $hook_name ) as $priority => $callbacks ) { + foreach ( $callbacks as $callback_id => $callback ) { + if ( isset( $previous_callbacks[ $priority ][ $callback_id ] ) ) { + continue; + } + + $previous_callbacks[ $priority ][ $callback_id ] = $callback; + + $buffer_level = ob_get_level(); + + call_user_func( $callback['function'] ); + + while ( ob_get_level() > $buffer_level ) { + ob_end_clean(); + } + } + } + + return $previous_callbacks; } } diff --git a/tests/php/integration/HCaptchaWPTestCase.php b/tests/php/integration/HCaptchaWPTestCase.php index a8185779f..10dca0ee0 100644 --- a/tests/php/integration/HCaptchaWPTestCase.php +++ b/tests/php/integration/HCaptchaWPTestCase.php @@ -169,11 +169,12 @@ class="hcaptcha-widget-id" /** * Get a honeypot field. * - * @param array $id The hCaptcha widget id. + * @param array $id The hCaptcha widget id. + * @param bool|null $honeypot Honeypot override. * * @return string */ - protected function get_hp_field( array $id ): string { + protected function get_hp_field( array $id, ?bool $honeypot = null ): string { $hp_name = 'hcap_hp_test'; $hp_sig = wp_create_nonce( $hp_name ); $hp_field = <<allow_honeypot_and_fst( true, $source, $form_id ); - return hcaptcha()->settings()->is_on( 'honeypot' ) ? $hp_field : ''; + $honeypot = null === $honeypot ? hcaptcha()->settings()->is_on( 'honeypot' ) : $honeypot; + + return $honeypot ? $hp_field : ''; } /** @@ -220,7 +223,7 @@ class="' . $args['hcaptcha_class'] . '" data-ajax="' . ( $args['ajax'] ? 'true' : 'false' ) . '" data-force="' . ( $args['force'] ? 'true' : 'false' ) . '"> - ' . $nonce_field . $this->get_hp_field( $args['id'] ); + ' . $nonce_field . $this->get_hp_field( $args['id'], $args['honeypot'] ); } /** @@ -242,6 +245,7 @@ private function prepare_hcap_form_args( array $args ): array { 'force' => false, 'theme' => '', 'size' => '', + 'honeypot' => null, 'id' => [], 'protect' => true, ], @@ -256,6 +260,11 @@ private function prepare_hcap_form_args( array $args ): array { $args['id'] ); + if ( null !== $args['honeypot'] ) { + $args['honeypot'] = filter_var( $args['honeypot'], FILTER_VALIDATE_BOOLEAN ); + $args['id']['honeypot'] = $args['honeypot']; + } + return $args; } diff --git a/tests/php/integration/Helpers/APITest.php b/tests/php/integration/Helpers/APITest.php index 678fb91d9..bf51eef54 100644 --- a/tests/php/integration/Helpers/APITest.php +++ b/tests/php/integration/Helpers/APITest.php @@ -202,6 +202,33 @@ public function test_verify_with_expected_widget_id(): void { ); } + /** + * Test verify() with a honeypot override in the expected widget id. + */ + public function test_verify_with_honeypot_override_in_expected_widget_id(): void { + $nonce_field_name = 'some nonce field'; + $nonce_action_name = 'some nonce action'; + $expected_id = [ + 'source' => [ 'test/source' ], + 'form_id' => 'test-form', + ]; + $actual_id = array_merge( $expected_id, [ 'honeypot' => false ] ); + + $this->prepare_verify_post( $nonce_field_name, $nonce_action_name ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $actual_id ); + + self::assertNull( + API::verify( + [ + 'nonce_name' => $nonce_field_name, + 'nonce_action' => $nonce_action_name, + 'expected_id' => $expected_id, + ] + ) + ); + } + /** * Test verify() with unexpected widget id. */ @@ -442,6 +469,74 @@ public function test_verify_request_honeypot_failure(): void { self::assertSame( 'Anti-spam check failed.', API::verify_request( $hcaptcha_response ) ); } + /** + * Test verify_request() with honeypot disabled by the signed widget id. + */ + public function test_verify_request_honeypot_disabled_by_widget_id(): void { + $hcaptcha_response = 'some response'; + + $this->prepare_verify_request( $hcaptcha_response ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [], + 'form_id' => 0, + 'honeypot' => false, + ] + ); + $_POST['hcap_hp_test'] = 'bot value'; + + self::assertNull( API::verify_request( $hcaptcha_response ) ); + } + + /** + * Test verify_request() with honeypot enabled by the signed widget id. + */ + public function test_verify_request_honeypot_enabled_by_widget_id(): void { + $hcaptcha_response = 'some response'; + + $this->prepare_verify_request( $hcaptcha_response ); + + $settings = (array) get_option( 'hcaptcha_settings', [] ); + $settings['honeypot'] = [ '' ]; + + update_option( 'hcaptcha_settings', $settings ); + hcaptcha()->init_hooks(); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [], + 'form_id' => 0, + 'honeypot' => true, + ] + ); + $_POST['hcap_hp_test'] = 'bot value'; + + self::assertSame( 'Anti-spam check failed.', API::verify_request( $hcaptcha_response ) ); + } + + /** + * Test verify_request() ignores a honeypot override with an invalid signature. + */ + public function test_verify_request_ignores_unsigned_honeypot_override(): void { + $hcaptcha_response = 'some response'; + + $this->prepare_verify_request( $hcaptcha_response ); + + $widget_id = HCaptcha::widget_id_value( + [ + 'source' => [], + 'form_id' => 0, + 'honeypot' => false, + ] + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = $widget_id . 'tampered'; + $_POST['hcap_hp_test'] = 'bot value'; + + self::assertSame( 'Anti-spam check failed.', API::verify_request( $hcaptcha_response ) ); + } + /** * Test verify_request() without a Form Submit Time object. * diff --git a/tests/php/integration/Helpers/HCaptchaTest.php b/tests/php/integration/Helpers/HCaptchaTest.php index 0e0245e57..42ae00fc5 100644 --- a/tests/php/integration/Helpers/HCaptchaTest.php +++ b/tests/php/integration/Helpers/HCaptchaTest.php @@ -144,6 +144,38 @@ public function test_form_display(): void { self::assertFalse( strpos( ob_get_clean(), ' false ] ); + + self::assertStringNotContainsString( 'name="hcap_hp_test"', $form ); + self::assertMatchesRegularExpression( '/name="hcaptcha-widget-id"\s+value="([^"]+)"/', $form ); + + preg_match( '/name="hcaptcha-widget-id"\s+value="([^"]+)"/', $form, $matches ); + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = $matches[1]; + + self::assertSame( + [ + 'source' => [], + 'form_id' => 0, + 'honeypot' => false, + ], + HCaptcha::get_widget_id() + ); + + $settings = (array) get_option( 'hcaptcha_settings', [] ); + $settings['honeypot'] = [ '' ]; + + update_option( 'hcaptcha_settings', $settings ); + hcaptcha()->init_hooks(); + + $form = HCaptcha::form( [ 'honeypot' => true ] ); + + self::assertStringContainsString( 'name="hcap_hp_test"', $form ); + } + /** * Test HCaptcha::form_display() with a delay API event. * diff --git a/tests/php/integration/Jetpack/BaseTest.php b/tests/php/integration/Jetpack/BaseTest.php index 332300462..a6416c08d 100644 --- a/tests/php/integration/Jetpack/BaseTest.php +++ b/tests/php/integration/Jetpack/BaseTest.php @@ -7,11 +7,11 @@ namespace HCaptcha\Tests\Integration\Jetpack; +use Automattic\Jetpack\Forms\ContactForm\Contact_Form as JetpackForm; +use Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin as JetpackFormPlugin; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Jetpack\Base; use HCaptcha\Jetpack\Form; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use Mockery; +use ReflectionClass; use ReflectionException; use tad\FunctionMocker\FunctionMocker; use WP_Error; @@ -21,7 +21,34 @@ * * @group jetpack */ -class BaseTest extends HCaptchaWPTestCase { +class BaseTest extends JetpackTestCase { + + /** + * Test that the live Jetpack Forms module is loaded. + * + * @return void + */ + public function test_live_plugin_is_loaded(): void { + $plugin_dir = realpath( WP_PLUGIN_DIR . '/jetpack' ); + $form_file = wp_normalize_path( ( new ReflectionClass( JetpackForm::class ) )->getFileName() ); + + self::assertTrue( is_plugin_active( 'jetpack/jetpack.php' ) ); + self::assertNotFalse( $plugin_dir ); + self::assertStringStartsWith( trailingslashit( wp_normalize_path( $plugin_dir ) ), $form_file ); + self::assertTrue( version_compare( constant( 'JETPACK__VERSION' ), '16.0.1', '>=' ) ); + self::assertTrue( \Jetpack::is_module_active( 'contact-form' ) ); + $shortcode_callback = $GLOBALS['shortcode_tags']['contact-form']; + + self::assertSame( JetpackForm::class, ltrim( $shortcode_callback[0], '\\' ) ); + self::assertSame( 'parse', $shortcode_callback[1] ); + self::assertSame( + 10, + has_filter( + 'jetpack_contact_form_is_spam', + [ JetpackFormPlugin::init(), 'is_spam_blocklist' ] + ) + ); + } /** * Tear down the test. @@ -51,12 +78,16 @@ public function tearDown(): void { * @dataProvider dp_test_init_hooks */ public function test_init_hooks( bool $is_editing_jetpack_form_post ): void { - $subject = Mockery::mock( Form::class )->makePartial(); + if ( $is_editing_jetpack_form_post ) { + $post_id = wp_insert_post( [ 'post_content' => '
'; + $form_ids = []; + + foreach ( [ 'first', 'second' ] as $index => $slug ) { + $form_ids[] = wp_insert_post( + [ + 'post_type' => 'kadence_form', + 'post_status' => 'publish', + 'post_title' => ucfirst( $slug ) . ' form', + 'post_content' => '', + ] + ); + } + + $document = ''; + + foreach ( $form_ids as $index => $form_id ) { + $document .= ''; + } new AdvancedForm(); $output = do_blocks( $document ); - foreach ( [ 123, 456 ] as $form_id ) { + foreach ( $form_ids as $form_id ) { $widget_id = HCaptcha::widget_id_value( [ 'source' => [ 'kadence-blocks/kadence-blocks.php' ], @@ -195,7 +235,9 @@ public function test_render_nested_forms_with_different_ids(): void { self::assertSame( 1, substr_count( $output, esc_attr( $widget_id ) ) ); } - self::assertSame( 456, BlockParser::$form_id ); + self::assertStringContainsString( 'class="wp-block-kadence-advanced-form', $output ); + self::assertStringContainsString( 'class="kb-button kt-button button kb-adv-form-submit-button', $output ); + self::assertSame( end( $form_ids ), BlockParser::$form_id ); } /** @@ -319,15 +361,18 @@ static function () use ( &$die_arr ) { } ); - $kb_ajax_advanced_form = Mockery::mock( 'alias:KB_Ajax_Advanced_Form' ); - $kb_ajax_advanced_form->shouldReceive( 'get_instance' )->once()->andReturn( $kb_ajax_advanced_form ); - $kb_ajax_advanced_form->shouldReceive( 'process_bail' ) - ->once()->with( $error_message, 'hCaptcha Failed' ); - $subject = Mockery::mock( AdvancedForm::class )->makePartial(); $subject->shouldAllowMockingProtectedMethods(); + ob_start(); + ob_start(); $subject->process_ajax(); + $json = ob_get_clean(); + $data = json_decode( $json, true ); + + self::assertFalse( $data['success'] ); + self::assertSame( $error_message, $data['data']['message'] ); + self::assertSame( 'hCaptcha Failed', $data['data']['console'] ); } /** @@ -351,15 +396,18 @@ static function () use ( &$die_arr ) { } ); - $kb_ajax_advanced_form = Mockery::mock( 'alias:KB_Ajax_Advanced_Form' ); - $kb_ajax_advanced_form->shouldReceive( 'get_instance' )->once()->andReturn( $kb_ajax_advanced_form ); - $kb_ajax_advanced_form->shouldReceive( 'process_bail' ) - ->once()->with( $error_message, 'hCaptcha Failed' ); - $subject = Mockery::mock( AdvancedForm::class )->makePartial(); $subject->shouldAllowMockingProtectedMethods(); + ob_start(); + ob_start(); $subject->process_ajax(); + $json = ob_get_clean(); + $data = json_decode( $json, true ); + + self::assertFalse( $data['success'] ); + self::assertSame( $error_message, $data['data']['message'] ); + self::assertSame( 'hCaptcha Failed', $data['data']['console'] ); } /** diff --git a/tests/php/integration/Kadence/BaseTest.php b/tests/php/integration/Kadence/BaseTest.php index e630f320a..19a7c78e5 100644 --- a/tests/php/integration/Kadence/BaseTest.php +++ b/tests/php/integration/Kadence/BaseTest.php @@ -8,7 +8,7 @@ namespace HCaptcha\Tests\Integration\Kadence; use HCaptcha\Kadence\Base; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use ReflectionException; /** @@ -17,7 +17,24 @@ * @group kadence * @group kadence-base */ -class BaseTest extends HCaptchaWPTestCase { +class BaseTest extends HCaptchaPluginWPTestCase { + + /** + * Kadence Blocks plugin entry file. + * + * @var string + */ + protected static $plugin = 'kadence-blocks/kadence-blocks.php'; + + /** + * Hooks to replay after loading Kadence Blocks. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; /** * Test init_hooks(). diff --git a/tests/php/integration/Kadence/BlockParserTest.php b/tests/php/integration/Kadence/BlockParserTest.php index 45e0ecf87..b889f0150 100644 --- a/tests/php/integration/Kadence/BlockParserTest.php +++ b/tests/php/integration/Kadence/BlockParserTest.php @@ -8,7 +8,7 @@ namespace HCaptcha\Tests\Integration\Kadence; use HCaptcha\Kadence\BlockParser; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; /** * Test Kadence BlockParser. @@ -16,7 +16,24 @@ * @group kadence * @group kadence-block-parser */ -class BlockParserTest extends HCaptchaWPTestCase { +class BlockParserTest extends HCaptchaPluginWPTestCase { + + /** + * Kadence Blocks plugin entry file. + * + * @var string + */ + protected static $plugin = 'kadence-blocks/kadence-blocks.php'; + + /** + * Hooks to replay after loading Kadence Blocks. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; /** * Set up test. diff --git a/tests/php/integration/Kadence/FormTest.php b/tests/php/integration/Kadence/FormTest.php index b86517ae5..db7fab5d2 100644 --- a/tests/php/integration/Kadence/FormTest.php +++ b/tests/php/integration/Kadence/FormTest.php @@ -14,8 +14,9 @@ use HCaptcha\Helpers\HCaptcha; use HCaptcha\Kadence\Form; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use Mockery; +use ReflectionClass; use ReflectionException; /** @@ -24,7 +25,37 @@ * @group kadence * @group kadence-form */ -class FormTest extends HCaptchaWPTestCase { +class FormTest extends HCaptchaPluginWPTestCase { + + /** + * Kadence Blocks plugin entry file. + * + * @var string + */ + protected static $plugin = 'kadence-blocks/kadence-blocks.php'; + + /** + * Hooks to replay after loading Kadence Blocks. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; + + /** + * Test that the live Kadence form block is registered. + * + * @return void + */ + public function test_live_plugin_is_loaded(): void { + $plugin_file = wp_normalize_path( ( new ReflectionClass( 'Kadence_Blocks_Form_Block' ) )->getFileName() ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/kadence-blocks/' ), $plugin_file ); + self::assertTrue( \WP_Block_Type_Registry::get_instance()->is_registered( 'kadence/form' ) ); + } /** * Tear down the test. diff --git a/tests/php/integration/LearnDash/RegisterTest.php b/tests/php/integration/LearnDash/RegisterTest.php new file mode 100644 index 000000000..2e88842a1 --- /dev/null +++ b/tests/php/integration/LearnDash/RegisterTest.php @@ -0,0 +1,120 @@ +add_captcha(); + $output = (string) ob_get_clean(); + $id = [ + 'source' => [ 'sfwd-lms/sfwd_lms.php' ], + 'form_id' => 'register', + ]; + + self::assertStringContainsString( HCaptcha::widget_id_value( $id ), $output ); + } + + /** + * Test that native WordPress registration defers to LearnDash. + * + * @return void + */ + public function test_wordpress_register_defers_to_learndash_owner(): void { + new Register(); + $wp_register = new WPRegister(); + $errors = new WP_Error( 'some-code', 'some-message' ); + + $_POST['action'] = 'register'; + $this->prepare_widget_id(); + + self::assertSame( $errors, $wp_register->verify( $errors, '', '' ) ); + } + + /** + * Test verification of the LearnDash owner. + * + * @return void + */ + public function test_verify(): void { + $subject = new Register(); + $errors = new WP_Error( 'some-code', 'some-message' ); + + $_POST['learndash-registration-form'] = '1'; + $this->prepare_widget_id(); + $this->prepare_verify_post( 'hcaptcha_learn_dash_register_nonce', 'hcaptcha_learn_dash_register' ); + + self::assertSame( $errors, $subject->verify( $errors, '', '' ) ); + } + + /** + * Prepare the LearnDash widget ID. + * + * @return void + */ + private function prepare_widget_id(): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'sfwd-lms/sfwd_lms.php' ], + 'form_id' => 'register', + ] + ); + } +} diff --git a/tests/php/integration/MailPoet/FormTest.php b/tests/php/integration/MailPoet/FormTest.php index 3aad8203c..3496ae002 100644 --- a/tests/php/integration/MailPoet/FormTest.php +++ b/tests/php/integration/MailPoet/FormTest.php @@ -14,53 +14,116 @@ use HCaptcha\Helpers\HCaptcha; use HCaptcha\MailPoet\Form; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use HCaptcha\Tests\Integration\Stubs\MailPoet\API\JSON\ResponseStub; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use MailPoet\API\JSON\API; -use MailPoet\API\JSON\Response; use MailPoet\API\JSON\ErrorResponse; +use MailPoet\API\JSON\Response; +use MailPoet\DI\ContainerWrapper; +use MailPoet\Entities\FormEntity; +use MailPoet\Form\FormsRepository; use Mockery; +use ReflectionClass; /** * Test Form class. * * @group mailpoet */ -class FormTest extends HCaptchaWPTestCase { +class FormTest extends HCaptchaPluginWPTestCase { /** - * Setup test. + * MailPoet plugin entry file. + * + * @var string */ - public function setUp(): void { - global $wpdb; + protected static $plugin = 'mailpoet/mailpoet.php'; - parent::setUp(); + /** + * Hooks to replay after loading MailPoet. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( - "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}mailpoet_forms - (id bigint(20) NOT NULL AUTO_INCREMENT, - updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id))" - ); - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, ordPress.DB.DirectDatabaseQuery.NoCaching - } + /** + * MailPoet can expose a recoverable migration query error as activation output on MariaDB. + * + * @var array + */ + protected static array $plugin_allowed_activation_errors = [ + 'mailpoet/mailpoet.php' => [ 'unexpected_output' ], + ]; /** * Tear down the test. */ public function tearDown(): void { - global $wpdb; - - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}mailpoet_forms" ); - // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching - unset( $_POST['action'], $_POST['endpoint'], $_POST['method'], $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] ); parent::tearDown(); } + /** + * Test a form persisted and rendered by the live MailPoet plugin. + * + * @return void + */ + public function test_live_mailpoet_form(): void { + $repository = ContainerWrapper::getInstance()->get( FormsRepository::class ); + $form = new FormEntity( 'hCaptcha integration form' ); + + $form->setBody( + [ + [ + 'id' => 'email', + 'name' => 'Email', + 'type' => 'text', + 'params' => [ + 'label' => 'Email Address', + 'required' => true, + 'label_within' => true, + ], + 'styles' => [ 'full_width' => true ], + ], + [ + 'id' => 'submit', + 'name' => 'Submit', + 'type' => 'submit', + 'params' => [ 'label' => 'Subscribe' ], + 'styles' => [ 'full_width' => true ], + ], + ] + ); + $form->setSettings( + [ + 'on_success' => 'message', + 'success_message' => 'Subscribed', + 'segments' => [], + 'segments_selected_by' => 'admin', + ] + ); + $form->setStyles( '' ); + + $repository->persist( $form ); + $repository->flush(); + + new Form(); + + $output = apply_filters( 'the_content', '[mailpoet_form id="' . $form->getId() . '"]' ); + $plugin_file = wp_normalize_path( ( new ReflectionClass( FormsRepository::class ) )->getFileName() ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/mailpoet/' ), $plugin_file ); + self::assertTrue( shortcode_exists( 'mailpoet_form' ) ); + self::assertStringContainsString( 'class="mailpoet_form ', $output ); + self::assertStringContainsString( 'value="Subscribe"', $output ); + self::assertStringContainsString( 'shouldReceive( 'send' )->once(); $api->shouldReceive( 'createErrorResponse' ) - ->with( $code, $error_message, ResponseStub::STATUS_UNAUTHORIZED ) + ->with( $code, $error_message, Response::STATUS_UNAUTHORIZED ) ->andReturn( $error_response ); $subject = new Form(); @@ -225,15 +285,12 @@ public function test_verify_missing_widget_id(): void { $code = 'bad-signature'; $error_message = 'Bad hCaptcha signature!'; - if ( ! class_exists( Response::class, false ) ) { - Mockery::namedMock( Response::class, ResponseStub::class ); - } $error_response = Mockery::mock( ErrorResponse::class ); $api = Mockery::mock( API::class ); $error_response->shouldReceive( 'send' )->once(); $api->shouldReceive( 'createErrorResponse' ) - ->with( $code, $error_message, ResponseStub::STATUS_UNAUTHORIZED ) + ->with( $code, $error_message, Response::STATUS_UNAUTHORIZED ) ->andReturn( $error_response ); $subject = new Form(); diff --git a/tests/php/integration/Mailchimp/FormTest.php b/tests/php/integration/Mailchimp/FormTest.php index 97b374770..814a2d3d1 100644 --- a/tests/php/integration/Mailchimp/FormTest.php +++ b/tests/php/integration/Mailchimp/FormTest.php @@ -12,17 +12,32 @@ use HCaptcha\Helpers\HCaptcha; use HCaptcha\Mailchimp\Form; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use MC4WP_Form; -use MC4WP_Form_Element; -use Mockery; /** * Test Form class. * * @group mailchimp */ -class FormTest extends HCaptchaWPTestCase { +class FormTest extends HCaptchaPluginWPTestCase { + + /** + * Mailchimp for WordPress plugin entry file. + * + * @var string + */ + protected static $plugin = 'mailchimp-for-wp/mailchimp-for-wp.php'; + + /** + * Hooks to replay after loading the plugin. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; /** * Tear down the test. @@ -31,6 +46,7 @@ class FormTest extends HCaptchaWPTestCase { */ public function tearDown(): void { unset( $_REQUEST['action'], $_REQUEST['nonce'], $_GET['mc4wp_preview_form'], $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] ); + MC4WP_Form::$instances = []; parent::tearDown(); } @@ -41,6 +57,10 @@ public function tearDown(): void { public function test_init_and_init_hooks(): void { $subject = new Form(); + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertTrue( function_exists( 'mc4wp_get_form' ) ); + self::assertTrue( class_exists( MC4WP_Form::class ) ); + self::assertTrue( shortcode_exists( 'mc4wp_form' ) ); self::assertSame( 10, has_filter( 'mc4wp_form_messages', [ $subject, 'add_hcap_error_messages' ] ) ); self::assertSame( 20, has_filter( 'mc4wp_form_content', [ $subject, 'add_hcaptcha' ] ) ); self::assertSame( 10, has_filter( 'mc4wp_form_errors', [ $subject, 'verify' ] ) ); @@ -51,7 +71,7 @@ public function test_init_and_init_hooks(): void { * Test add_hcap_error_messages(). */ public function test_add_hcap_error_messages(): void { - $form = Mockery::mock( MC4WP_Form::class ); + $form = $this->create_form(); $messages = [ 'foo' => [ @@ -161,9 +181,10 @@ public function test_add_hcap_error_messages(): void { * Test add_hcaptcha(). */ public function test_add_hcaptcha(): void { - $form_id = 5; $content = ''; $content_with_hcaptcha = 'Some content with hCaptcha ...'; + $mc4wp_form = $this->create_form( $content ); + $form_id = $mc4wp_form->ID; $args = [ 'action' => 'hcaptcha_mailchimp', 'name' => 'hcaptcha_mailchimp_nonce', @@ -173,11 +194,7 @@ public function test_add_hcaptcha(): void { ], ]; $expected = $this->get_hcap_form( $args ) . $content; - - $mc4wp_form = Mockery::mock( MC4WP_Form::class ); - $mc4wp_form->ID = $form_id; - - $element = Mockery::mock( MC4WP_Form_Element::class ); + $element = $mc4wp_form->get_element(); $subject = new Form(); @@ -186,19 +203,39 @@ public function test_add_hcaptcha(): void { } /** - * Test add_hcaptcha() with built-in form interaction. + * Test hCaptcha in a form rendered by the live Mailchimp plugin. * * @return void */ - public function test_add_hcaptcha_with_form_interaction(): void { - $form_id = 5; - $content = ''; + public function test_live_mailchimp_form(): void { + $content = '

' . + '

'; + $form = $this->create_form( $content ); - $mc4wp_form = Mockery::mock( MC4WP_Form::class ); - $mc4wp_form->ID = $form_id; + update_option( 'mc4wp', [ 'api_key' => 'test-us1' ] ); - $element = Mockery::mock( MC4WP_Form_Element::class ); - $subject = new Form(); + new Form(); + + $output = do_shortcode( sprintf( '[mc4wp_form id="%d"]', $form->ID ) ); + + self::assertStringContainsString( 'class="mc4wp-form mc4wp-form-' . $form->ID, $output ); + self::assertStringContainsString( 'name="EMAIL"', $output ); + self::assertStringContainsString( 'name="_mc4wp_form_id" value="' . $form->ID . '"', $output ); + self::assertStringContainsString( 'create_form( $content ); + $element = $mc4wp_form->get_element(); + $subject = new Form(); add_filter( 'hcap_delay_api_event', '__return_true' ); @@ -215,14 +252,12 @@ public function test_add_hcaptcha_with_form_interaction(): void { * Test verify(). */ public function test_verify(): void { - $form_id = 5; + $mc4wp_form = $this->create_form(); + $form_id = $mc4wp_form->ID; $this->prepare_verify_post( 'hcaptcha_mailchimp_nonce', 'hcaptcha_mailchimp' ); $this->prepare_widget_id( $form_id ); - $mc4wp_form = Mockery::mock( MC4WP_Form::class ); - $mc4wp_form->ID = $form_id; - $subject = new Form(); self::assertSame( [], $subject->verify( [], $mc4wp_form ) ); @@ -232,18 +267,15 @@ public function test_verify(): void { * Test verify() with a shortcode. */ public function test_verify_with_shortcode(): void { - $form_id = 5; - $name = 'some_nonce'; - $action = 'some'; + $name = 'some_nonce'; + $action = 'some'; + $content = sprintf( '[hcaptcha name="%s" action="%s"]', $name, $action ); + $mc4wp_form = $this->create_form( $content ); + $form_id = $mc4wp_form->ID; $this->prepare_verify_post( 'hcaptcha_mailchimp_nonce', 'hcaptcha_mailchimp' ); $this->prepare_widget_id( $form_id ); - $mc4wp_form = Mockery::mock( MC4WP_Form::class ); - $mc4wp_form->ID = $form_id; - - $mc4wp_form->content = sprintf( '[hcaptcha name="%s" action="%s"]', $name, $action ); - $subject = new Form(); self::assertSame( [], $subject->verify( [], $mc4wp_form ) ); @@ -253,14 +285,12 @@ public function test_verify_with_shortcode(): void { * Test verify() not verified. */ public function test_verify_not_verified(): void { - $form_id = 5; + $mc4wp_form = $this->create_form(); + $form_id = $mc4wp_form->ID; $this->prepare_verify_post( 'hcaptcha_mailchimp_nonce', 'hcaptcha_mailchimp', false ); $this->prepare_widget_id( $form_id ); - $mc4wp_form = Mockery::mock( MC4WP_Form::class ); - $mc4wp_form->ID = $form_id; - $subject = new Form(); self::assertSame( [ 'fail' ], $subject->verify( [], $mc4wp_form ) ); @@ -270,7 +300,8 @@ public function test_verify_not_verified(): void { * Test verify() with a bad widget id. */ public function test_verify_bad_widget_id(): void { - $form_id = 5; + $mc4wp_form = $this->create_form(); + $form_id = $mc4wp_form->ID; $this->prepare_verify_post( 'hcaptcha_mailchimp_nonce', 'hcaptcha_mailchimp' ); $this->prepare_widget_id( @@ -281,9 +312,6 @@ public function test_verify_bad_widget_id(): void { ] ); - $mc4wp_form = Mockery::mock( MC4WP_Form::class ); - $mc4wp_form->ID = $form_id; - $subject = new Form(); self::assertSame( [ 'bad-signature' ], $subject->verify( [], $mc4wp_form ) ); @@ -316,7 +344,7 @@ public function test_preview_scripts(): void { 'data' => 'var HCaptchaMailchimpObject = ' . json_encode( $params, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) . ';', ]; - $subject = Mockery::mock( Form::class )->makePartial(); + $subject = new Form(); self::assertFalse( wp_script_is( $admin_handle ) ); @@ -338,6 +366,37 @@ public function test_preview_scripts(): void { self::assertSame( $expected_extra, $script->extra ); } + /** + * Create a form through the live Mailchimp plugin model. + * + * @param string $content Form HTML. + * + * @return MC4WP_Form + * @noinspection PhpUndefinedFunctionInspection + */ + private function create_form( string $content = '' ): MC4WP_Form { + $current_user_id = get_current_user_id(); + $admin_user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + $post_data = [ + 'post_title' => 'Mailchimp Test Form', + 'post_content' => $content, + 'post_status' => 'publish', + 'post_type' => 'mc4wp-form', + ]; + + wp_set_current_user( $admin_user_id ); + + try { + $form_id = $this->factory()->post->create( $post_data ); + } finally { + wp_set_current_user( $current_user_id ); + } + + unset( MC4WP_Form::$instances[ $form_id ] ); + + return mc4wp_get_form( $form_id ); + } + /** * Prepare widget id. * diff --git a/tests/php/integration/Maintenance/LoginTest.php b/tests/php/integration/Maintenance/LoginTest.php index 0a08bbe0a..26988a485 100644 --- a/tests/php/integration/Maintenance/LoginTest.php +++ b/tests/php/integration/Maintenance/LoginTest.php @@ -15,7 +15,8 @@ use HCaptcha\Helpers\HCaptcha; use HCaptcha\Helpers\Utils; use HCaptcha\Maintenance\Login; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; +use MTNC; use tad\FunctionMocker\FunctionMocker; use WP_Error; use WP_User; @@ -26,7 +27,14 @@ * @group maintenance-login * @group maintenance */ -class LoginTest extends HCaptchaWPTestCase { +class LoginTest extends HCaptchaPluginWPTestCase { + + /** + * Maintenance plugin entry file. + * + * @var string + */ + protected static $plugin = 'maintenance/maintenance.php'; /** * Tear down the test. @@ -42,6 +50,10 @@ public function tearDown(): void { * Test constructor and init_hooks(). */ public function test_constructor_and_init_hooks(): void { + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertTrue( class_exists( 'MTNC', false ) ); + self::assertInstanceOf( MTNC::class, $GLOBALS['wf_mtnc'] ); + $subject = new Login(); self::assertSame( 10, has_action( 'mtnc_load_options_style', [ hcaptcha(), 'print_inline_styles' ] ) ); @@ -54,6 +66,8 @@ public function test_constructor_and_init_hooks(): void { /** * Test render flow. + * + * @noinspection PhpUnusedLocalVariableInspection */ public function test_render(): void { $footer_scripts = ''; @@ -83,7 +97,6 @@ static function () use ( $footer_scripts ) { ); add_filter( 'script_loader_tag', 'mtnc_defer_scripts', 10, 2 ); - $form = ''; $args = [ 'action' => 'hcaptcha_maintenance_login', 'name' => 'hcaptcha_maintenance_login_nonce', @@ -93,37 +106,19 @@ static function () use ( $footer_scripts ) { ], ]; $hcap_form = $this->get_hcap_form( $args ); - $search = 'after_main_container(); - - ob_start(); - - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo $form; - - $subject->add_hcaptcha(); - - $output = ob_get_clean(); - - // hCaptcha should be injected before the 'submit' input and footer scripts should be printed. - self::assertSame( 0, strpos( $output, $expected ) ); + $output = $this->render_maintenance_page(); + + // The real Maintenance login form should contain hCaptcha before the `submit` input. + self::assertStringContainsString( '
after_main_container(); - - ob_start(); - - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo $form; - - $subject->add_hcaptcha(); - - $output = ob_get_clean(); + $output = $this->render_maintenance_page(); remove_filter( 'hcap_delay_api_event', '__return_true' ); @@ -134,8 +129,6 @@ static function () use ( $footer_scripts ) { * Test render flow when an error message is set by verify(): it should be displayed in span.login-error. */ public function test_render_injection_with_error_message(): void { - $footer_scripts = ''; - $subject = new Login(); // Simulate Maintenance custom login POST and exceeded limit. @@ -152,42 +145,17 @@ public function test_render_injection_with_error_message(): void { $subject->verify( $user, 'pass' ); - // Start buffering and emit a form with a login-error span. - $subject->after_main_container(); - - add_action( - 'wp_print_footer_scripts', - static function () use ( $footer_scripts ) { - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo $footer_scripts; - } - ); - - $form = '
'; - $args = [ - 'action' => 'hcaptcha_maintenance_login', - 'name' => 'hcaptcha_maintenance_login_nonce', - 'id' => [ - 'source' => [ 'maintenance/maintenance.php' ], - 'form_id' => 'login', - ], - ]; - $hcap_form = $this->get_hcap_form( $args ); - $search = ').*?()#', '$1hCaptcha error: The hCaptcha is invalid.$2', $expected ); - - ob_start(); - - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo $form; - - $subject->add_hcaptcha(); + // Keep the hCaptcha error on the integration while rendering an unsubmitted real form. + unset( $_POST['is_custom_login'] ); - $output = ob_get_clean(); + $output = $this->render_maintenance_page(); - // hCaptcha should be injected before the 'submit' input and footer scripts should be printed. - self::assertSame( 0, strpos( $output, $expected ) ); + self::assertStringContainsString( ' + +HTML; + + $integration = new Form(); + $class_file = wp_normalize_path( ( new ReflectionClass( Form_Data_Request::class ) )->getFileName() ); + $html = do_blocks( $content ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( wp_normalize_path( WP_PLUGIN_DIR . '/otter-blocks/' ), $class_file ); + self::assertTrue( \WP_Block_Type_Registry::get_instance()->is_registered( 'themeisle-blocks/form' ) ); + self::assertSame( 10, has_filter( 'otter_form_anti_spam_validation', [ $integration, 'verify' ] ) ); + self::assertStringContainsString( 'otter-form__container', $html ); + self::assertStringContainsString( 'class="h-captcha"', $html ); + self::assertStringContainsString( 'hcaptcha_otter_nonce', $html ); + self::assertStringNotContainsString( 'has-captcha', $html ); + + $this->prepare_verify_post( 'hcaptcha_otter_nonce', 'hcaptcha_otter' ); + + $request_data = [ + 'payload' => [ + 'formId' => 'wp-block-themeisle-blocks-form-' . $form_id, + 'antiSpamTime' => 5000, + 'antiSpamHoneyPot' => '', + 'postId' => wp_insert_post( + [ + 'post_title' => 'Otter form page', + 'post_status' => 'publish', + ] + ), + 'formInputsData' => [ + [ + 'id' => 'name', + 'label' => 'Name', + 'type' => 'text', + 'value' => 'John Doe', + ], + ], + ], + 'h-captcha-response' => 'some response', + 'hcaptcha-widget-id' => HCaptcha::widget_id_value( + [ + 'source' => [ static::$plugin ], + 'form_id' => $form_id, + ] + ), + ]; + + foreach ( [ 'hcaptcha_otter_nonce', 'hcap_fst_token', 'hcap_hp_test', 'hcap_hp_sig' ] as $key ) { + $request_data[ $key ] = sanitize_text_field( wp_unslash( $_POST[ $key ] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + } + + $request = new WP_REST_Request( 'POST', '/otter/v1/form/frontend' ); + $request->set_body( wp_json_encode( $request_data ) ); + + $form_data = new Form_Data_Request( $request ); + $result = apply_filters( 'otter_form_anti_spam_validation', $form_data ); + + self::assertSame( $form_data, $result ); + self::assertFalse( $result->has_error() ); + } /** * Test init_hooks(). diff --git a/tests/php/integration/PaidMembershipsPro/CheckoutTest.php b/tests/php/integration/PaidMembershipsPro/CheckoutTest.php index 2ffb174e2..369b672fb 100644 --- a/tests/php/integration/PaidMembershipsPro/CheckoutTest.php +++ b/tests/php/integration/PaidMembershipsPro/CheckoutTest.php @@ -9,15 +9,39 @@ use HCaptcha\Helpers\HCaptcha; use HCaptcha\PaidMembershipsPro\Checkout; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; -use tad\FunctionMocker\FunctionMocker; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; +use ReflectionFunction; /** * Test Checkout class. * * @group paid-memberships-pro */ -class CheckoutTest extends HCaptchaWPTestCase { +class CheckoutTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative path. + * + * @var string + */ + protected static $plugin = 'paid-memberships-pro/paid-memberships-pro.php'; + + /** + * Hooks to replay after loading Paid Memberships Pro. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; + + /** + * Force lifecycle hook replay after WPTestCase resets action counters. + * + * @var bool + */ + protected static bool $force_plugin_load_hooks = true; /** * Tear down the test. @@ -26,6 +50,7 @@ class CheckoutTest extends HCaptchaWPTestCase { */ public function tearDown(): void { unset( $GLOBALS['pmpro_msg'], $GLOBALS['pmpro_msgt'] ); + unset( $_REQUEST['submit-checkout'] ); parent::tearDown(); } @@ -64,6 +89,56 @@ public function test_add_captcha(): void { self::assertStringContainsString( 'hcaptcha_pmpro_checkout_nonce', $output ); } + /** + * Test hCaptcha in the live Paid Memberships Pro checkout template. + * + * @return void + */ + public function test_live_checkout_form(): void { + global $pmpro_level, $wpdb; + + pmpro_db_delta(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Create a PMPro membership level fixture. + $wpdb->insert( + $wpdb->pmpro_membership_levels, + [ + 'name' => 'Integration Level', + 'description' => 'Live Paid Memberships Pro checkout.', + 'confirmation' => '', + 'initial_payment' => 0, + 'billing_amount' => 0, + 'cycle_number' => 0, + 'cycle_period' => 'Month', + 'billing_limit' => 0, + 'trial_amount' => 0, + 'trial_limit' => 0, + 'allow_signups' => 1, + ], + [ '%s', '%s', '%s', '%f', '%f', '%d', '%s', '%d', '%f', '%d', '%d' ] + ); + + $level_id = (int) $wpdb->insert_id; + + $pmpro_level = pmpro_getLevel( $level_id ); + + $subject = new Checkout(); + + $template = new ReflectionFunction( 'pmpro_loadTemplate' ); + + $html = pmpro_loadTemplate( 'checkout', 'local', 'pages' ); + + self::assertTrue( is_plugin_active( static::$plugin ) ); + self::assertStringStartsWith( + wp_normalize_path( WP_PLUGIN_DIR . '/paid-memberships-pro/' ), + wp_normalize_path( (string) $template->getFileName() ) + ); + self::assertStringContainsString( 'id="pmpro_form"', $html ); + self::assertStringContainsString( 'name="pmpro_level" value="' . $level_id . '"', $html ); + self::assertStringContainsString( 'name="hcaptcha_pmpro_checkout_nonce"', $html ); + self::assertSame( 10, has_action( 'pmpro_checkout_before_submit_button', [ $subject, 'add_captcha' ] ) ); + } + /** * Test verify(). * @@ -72,7 +147,7 @@ public function test_add_captcha(): void { public function test_verify(): void { global $pmpro_msg, $pmpro_msgt; - FunctionMocker::replace( 'pmpro_was_checkout_form_submitted', true ); + $_REQUEST['submit-checkout'] = '1'; $this->prepare_verify_post( 'hcaptcha_pmpro_checkout_nonce', 'hcaptcha_pmpro_checkout' ); $this->prepare_widget_id(); @@ -93,7 +168,7 @@ public function test_verify(): void { public function test_verify_not_verified(): void { global $pmpro_msg, $pmpro_msgt; - FunctionMocker::replace( 'pmpro_was_checkout_form_submitted', true ); + $_REQUEST['submit-checkout'] = '1'; $this->prepare_verify_post( 'hcaptcha_pmpro_checkout_nonce', 'hcaptcha_pmpro_checkout', false ); $this->prepare_widget_id(); @@ -114,8 +189,6 @@ public function test_verify_not_verified(): void { public function test_verify_not_submitted(): void { global $pmpro_msg, $pmpro_msgt; - FunctionMocker::replace( 'pmpro_was_checkout_form_submitted', false ); - $subject = new Checkout(); $subject->verify(); diff --git a/tests/php/integration/PaidMembershipsPro/LoginTest.php b/tests/php/integration/PaidMembershipsPro/LoginTest.php index 9ae6945dc..ee548f658 100644 --- a/tests/php/integration/PaidMembershipsPro/LoginTest.php +++ b/tests/php/integration/PaidMembershipsPro/LoginTest.php @@ -8,14 +8,39 @@ namespace HCaptcha\Tests\Integration\PaidMembershipsPro; use HCaptcha\PaidMembershipsPro\Login; -use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; +use ReflectionFunction; /** * Test Login class. * * @group paid-memberships-pro */ -class LoginTest extends HCaptchaWPTestCase { +class LoginTest extends HCaptchaPluginWPTestCase { + + /** + * Plugin relative path. + * + * @var string + */ + protected static $plugin = 'paid-memberships-pro/paid-memberships-pro.php'; + + /** + * Hooks to replay after loading Paid Memberships Pro. + * + * @var string[] + */ + protected static array $plugin_load_hooks = [ + 'plugins_loaded', + 'init', + ]; + + /** + * Force lifecycle hook replay after WPTestCase resets action counters. + * + * @var bool + */ + protected static bool $force_plugin_load_hooks = true; /** * Tear down the test. @@ -122,6 +147,33 @@ public function test_add_pmpro_captcha(): void { self::assertStringContainsString( '