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..a06e21b5d 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,837 @@ 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: 780px; + min-width: 860px; + min-height: min(780px, calc(100vh - 48px)); + max-width: calc(100vw - 32px); + max-height: calc(100vh - 48px); + 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-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-section-custom + table tbody tr td select { +.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 { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + padding: 14px; + overflow: hidden; + 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 { + flex: 1 1 auto; + 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-custom-themes-warning { + color: #d63638; + font-weight: 600; +} + +.hcaptcha-theme-editor-footer { + flex: 0 0 auto; + justify-content: flex-end; + padding: 12px 18px; + border-top: 1px solid #c3c4c7; +} + +.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 +1184,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: visible; + } + + .hcaptcha-theme-editor-preview-stage { + min-height: 365px; + } +} + @media (max-width: 600px) { .hcaptcha-section-keys + table tbody { grid-template-columns: repeat(2, 1fr) !important; @@ -411,15 +1244,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 000000000..6146cdbd5 Binary files /dev/null and b/assets/images/advanced-theme-editor.jpg differ 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..0dfbc107e 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,578 @@ 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; - configParamsJson = configParamsJson ? configParamsJson : null; + pruneDefaultThemeValues( normalized.theme, getDefaultThemeForMode( mode ) ); + + 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..f88a9eedf 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,26 +297,31 @@ 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', () => { + test( 'applyCustomThemes: disabled Custom Themes changes only the editor preview', () => { bootGeneral(); const $custom = $( "input[name='hcaptcha_settings[custom_themes][]']" ); $custom.prop( 'checked', false ); const $cfg = $( "textarea[name='hcaptcha_settings[config_params]']" ); - $cfg.val( '{"foo":1}' ).trigger( 'input' ); - expect( hCaptcha.setParams ).toHaveBeenCalled(); - const lastCallArg = hCaptcha.setParams.mock.calls.slice( -1 )[ 0 ][ 0 ]; - expect( lastCallArg ).toEqual( expect.objectContaining( { - sitekey: 'live-key', - hl: 'en', - } ) ); + + hCaptcha.setParams.mockClear(); + $cfg.val( '{"theme":{"palette":{"primary":{"main":"#123456"}}}}' ).trigger( 'input' ); + + expect( hCaptcha.setParams ).not.toHaveBeenCalled(); + expect( $( '.hcaptcha-theme-editor-json-status' ).hasClass( 'is-valid' ) ).toBe( true ); } ); test( 'scriptUpdate: rebuilds API script with params and clears sample', async () => { @@ -358,6 +430,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 +738,302 @@ 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( 'editor remains available when Custom Themes is disabled', () => { 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 $editor = $( '.hcaptcha-theme-editor' ); + const $launcher = $( '.hcaptcha-theme-editor-launcher' ); + const $open = $( '[data-theme-editor-open]' ); + const $previewBehavior = $( '[data-theme-editor-preview-behavior]' ); + const $customThemesWarning = $( '[data-theme-editor-custom-themes-warning]' ); - // Initially, unchecked — fields should be disabled. - expect( $prop.prop( 'disabled' ) ).toBe( true ); - expect( $val.prop( 'disabled' ) ).toBe( true ); - expect( $cfg.prop( 'disabled' ) ).toBe( true ); + // Initially, unchecked — the editor stays available in preview-only mode. + expect( $cfg.prop( 'disabled' ) ).toBe( false ); + expect( $launcher.hasClass( 'is-disabled' ) ).toBe( false ); + expect( $open.prop( 'disabled' ) ).toBe( false ); + expect( $editor.attr( 'aria-disabled' ) ).toBe( 'false' ); + expect( $previewBehavior.text() ).toBe( 'Changes are shown in this preview only.' ); + expect( $customThemesWarning.prop( 'hidden' ) ).toBe( false ); - // Check it. + $open.trigger( 'click' ); + expect( $editor.prop( 'hidden' ) ).toBe( false ); + + hCaptcha.setParams.mockClear(); + $( '[data-theme-path="theme.palette.primary.main"]' ).val( '#123456' ).trigger( 'change' ); + expect( hCaptcha.setParams ).not.toHaveBeenCalled(); + + // Enabling Custom Themes restores live updates without closing the editor. $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 ); + expect( $editor.prop( 'hidden' ) ).toBe( false ); + expect( $previewBehavior.text() ).toBe( 'The real widget in Keys also updates live.' ); + expect( $customThemesWarning.prop( 'hidden' ) ).toBe( true ); + expect( hCaptcha.setParams ).toHaveBeenCalled(); } ); - 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 $editor = $( '.hcaptcha-theme-editor' ); + const $open = $( '[data-theme-editor-open]' ); + + $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( 'component navigation renders grouped fields', () => { + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + const $checkboxButton = $( '[data-theme-group="component--checkbox"]' ); + + expect( $checkboxButton.length ).toBe( 1 ); + $checkboxButton.trigger( 'click' ); + + 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( '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', + ] ) ); + + $( '[data-theme-group="component--checkbox"]' ).trigger( 'click' ); + + expect( getLabels() ).toEqual( expect.arrayContaining( [ + 'Main - Fill', + 'Main - Border', + 'Hover - Fill', + ] ) ); } ); - 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( 'visual color change updates JSON and live preview', () => { + const $status = $( '.hcaptcha-theme-editor-dirty' ).first(); - // Select the color option. - $prop.find( 'option' ).eq( 2 ).prop( 'selected', true ); - $prop.trigger( 'change' ); + expect( $status.prop( 'hidden' ) ).toBe( true ); + expect( $status.text() ).toBe( '' ); - expect( $val.attr( 'type' ) ).toBe( 'color' ); + $( "input[name='hcaptcha_settings[custom_themes][]']" ).prop( 'checked', true ).trigger( 'change' ); + hCaptcha.setParams.mockClear(); + + $( '[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' ); } ); - 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( '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' ); - // Select palette--mode option. - $prop.find( 'option' ).eq( 1 ).prop( 'selected', true ); - $prop.trigger( 'change' ); + const config = JSON.parse( $cfg.val() ); + const previewParams = hCaptcha.setParams.mock.calls.slice( -1 )[ 0 ][ 0 ]; - expect( $val.attr( 'type' ) ).toBe( 'text' ); - expect( $val.val() ).toBe( 'light' ); + 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( 'customValue input triggers applyCustomThemes with nested params', () => { - const $prop = $( '.hcaptcha-general-custom-prop select' ); - const $val = $( '.hcaptcha-general-custom-value input' ); + 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' ); - // Select palette--mode option. - $prop.find( 'option' ).eq( 1 ).prop( 'selected', true ); - $prop.trigger( 'change' ); + 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 ); + } ); - // Change value. - $val.val( 'dark' ).trigger( 'input' ); + 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' ); - // Verify setParams was called (applyCustomThemes calls hCaptchaUpdate, which calls setParams). - expect( hCaptcha.setParams ).toHaveBeenCalled(); + 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' ); } ); -} ); -describe( 'syncConfigParams recursion and selected prop update', () => { - beforeEach( () => { - jest.clearAllMocks(); - bootGeneral(); + 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]']" ); + + $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( 'applyCustomThemes with nested theme object updates option values and selected custom value', () => { + 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 $val = $( '.hcaptcha-general-custom-value input' ); - const $prop = $( '.hcaptcha-general-custom-prop select' ); + const $note = $( '[data-theme-editor-preview-note]' ); + + expect( $note.children( 'br' ) ).toHaveLength( 1 ); + + $cfg.val( '{bad json' ).trigger( 'input' ); + + expect( $note.text() ).toBe( 'Showing the last valid config.' ); + expect( $note.children( 'br' ) ).toHaveLength( 0 ); + + $cfg.val( '{}' ).trigger( 'input' ); + + expect( $note.children( '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 ); - // Select a palette--mode option (which is selected by default). - $prop.find( 'option' ).eq( 1 ).prop( 'selected', true ); + $( '[data-theme-editor-open]' ).trigger( 'click' ); + expect( $editor.prop( 'hidden' ) ).toBe( false ); - // Set config with a nested theme containing palette.mode. - $cfg.val( '{"theme":{"palette":{"mode":"dark"}}}' ).trigger( 'input' ); + hCaptcha.setParams.mockClear(); + $( '[data-theme-color-path="palette--primary--main"]' ).val( '#654321' ).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( $( '[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 +1071,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( '