From e82612391dd80f2832f4fba7c17e3b5aef8e92d5 Mon Sep 17 00:00:00 2001 From: KAGG Design Date: Sat, 4 Jul 2026 15:59:02 +0300 Subject: [PATCH] Version 5.1.0. --- .../_support/FunctionMockerExtension.php | 37 + .gitignore | 1 + assets/css/integrations.css | 8 +- assets/css/settings-base.css | 115 +- assets/css/settings-list-page-base.css | 7 + assets/css/support-modal.css | 347 +++ assets/css/whats-new.css | 75 + assets/js/hcaptcha-acfe.js | 94 +- assets/js/hcaptcha-blocksy.js | 54 + assets/js/hcaptcha-fst.js | 2 + assets/js/hcaptcha-givewp.js | 2 +- assets/js/hcaptcha-mailpoet.js | 24 +- assets/js/hcaptcha-otter.js | 7 +- assets/js/support-modal.js | 391 +++ assets/js/whats-new.js | 153 +- changelog.txt | 16 + codeception.yml | 1 + composer.json | 5 +- hcaptcha.php | 6 +- package.json | 42 +- readme.txt | 511 +--- src/php/ACFE/Form.php | 33 +- src/php/Abilities/Abilities.php | 42 +- src/php/Admin/Events/Events.php | 14 +- src/php/Admin/Privacy.php | 2 + src/php/Admin/SupportModal.php | 477 +++ src/php/Admin/WhatsNew.php | 141 +- src/php/AutoVerify/AutoVerify.php | 2 +- src/php/BeaverBuilder/Base.php | 22 +- src/php/BeaverBuilder/Contact.php | 1 + src/php/BeaverBuilder/Login.php | 10 +- src/php/Blocksy/NewsletterSubscribe.php | 66 +- src/php/Blocksy/Waitlist.php | 66 +- src/php/BuddyPress/CreateGroup.php | 13 + src/php/BuddyPress/Register.php | 27 +- src/php/CoBlocks/Form.php | 24 + src/php/DelayedScript/DelayedScript.php | 63 +- src/php/DownloadManager/DownloadManager.php | 40 +- src/php/EssentialBlocks/Form.php | 60 +- src/php/FluentForm/Form.php | 46 +- src/php/FormidableForms/Form.php | 24 +- src/php/Forminator/Form.php | 30 +- src/php/GiveWP/Base.php | 72 +- src/php/GravityForms/Field.php | 17 +- src/php/GravityForms/Form.php | 62 +- src/php/HTMLForms/Form.php | 7 +- src/php/Helpers/API.php | 20 +- src/php/Helpers/HCaptcha.php | 26 +- src/php/Jetpack/Base.php | 10 +- src/php/Jetpack/Form.php | 140 +- src/php/Kadence/AdvancedForm.php | 15 + src/php/Kadence/Form.php | 16 + src/php/MailPoet/Form.php | 17 +- src/php/Main.php | 41 +- src/php/Migrations/Migrations.php | 155 +- src/php/NF/Field.php | 21 +- src/php/NF/NF.php | 7 +- src/php/Otter/Form.php | 28 +- src/php/PaidMembershipsPro/Checkout.php | 28 +- src/php/PasswordProtected/Protect.php | 27 +- src/php/Settings/Integrations.php | 2 +- src/php/Settings/SystemInfo.php | 19 +- src/php/UM/Base.php | 39 +- src/php/WC/Checkout.php | 14 +- tests/js/assets-js-files/admin-cf7.test.js | 51 + .../admin-elementor-pro.test.js | 9 + .../assets-js-files/admin-fluentform.test.js | 6 + .../assets-js-files/admin-forminator.test.js | 10 + .../admin-kadence-advanced.test.js | 118 + .../assets-js-files/admin-mailchimp.test.js | 217 ++ tests/js/assets-js-files/admin-nf.test.js | 234 ++ tests/js/assets-js-files/admin-quform.test.js | 135 + tests/js/assets-js-files/anti-spam.test.js | 71 + .../js/assets-js-files/coverage-fill.test.js | 597 ++++ tests/js/assets-js-files/general.test.js | 44 + .../js/assets-js-files/hcaptcha-acfe.test.js | 250 +- .../hcaptcha-auto-verify.test.js | 141 + .../js/assets-js-files/hcaptcha-avada.test.js | 44 + .../hcaptcha-back-in-stock-notifier.test.js | 90 + .../assets-js-files/hcaptcha-blocksy.test.js | 80 + .../js/assets-js-files/hcaptcha-brizy.test.js | 110 + .../hcaptcha-customer-reviews.test.js | 187 ++ .../hcaptcha-divi-email-optin.test.js | 97 + .../hcaptcha-essential-blocks.test.js | 299 ++ .../hcaptcha-fluentform.test.js | 344 +++ .../hcaptcha-forminator.test.js | 31 + tests/js/assets-js-files/hcaptcha-fst.test.js | 52 + .../assets-js-files/hcaptcha-givewp.test.js | 239 ++ .../hcaptcha-gravity-forms.test.js | 68 + .../assets-js-files/hcaptcha-helper.test.js | 227 ++ .../hcaptcha-icegram-express.test.js | 75 + .../assets-js-files/hcaptcha-jetpack.test.js | 77 + .../hcaptcha-kadence-advanced.test.js | 81 + .../assets-js-files/hcaptcha-kadence.test.js | 102 + .../hcaptcha-login-signup-popup.test.js | 29 + .../assets-js-files/hcaptcha-mailpoet.test.js | 180 ++ .../js/assets-js-files/hcaptcha-otter.test.js | 88 + .../assets-js-files/hcaptcha-passster.test.js | 95 + .../assets-js-files/hcaptcha-quform.test.js | 31 + .../hcaptcha-sendinblue.test.js | 67 + .../hcaptcha-simple-download-monitor.test.js | 38 + .../assets-js-files/hcaptcha-spectra.test.js | 236 ++ .../hcaptcha-ultimate-addons.test.js | 139 + .../assets-js-files/hcaptcha-users-wp.test.js | 37 + .../hcaptcha-wc-block-checkout.test.js | 134 + ...-woocommerce-paypal-payments-early.test.js | 182 ++ ...aptcha-woocommerce-paypal-payments.test.js | 429 +++ .../hcaptcha-wpdiscuz-comment.test.js | 105 + .../assets-js-files/hcaptcha-wpforms.test.js | 74 + tests/js/assets-js-files/integrations.test.js | 120 + tests/js/assets-js-files/kagg-dialog.test.js | 30 + .../js/assets-js-files/notifications.test.js | 50 + .../assets-js-files/onboarding-wizard.test.js | 225 +- .../js/assets-js-files/settings-base.test.js | 90 + .../js/assets-js-files/support-modal.test.js | 587 ++++ tests/js/assets-js-files/whats-new.test.js | 178 +- tests/php/_bootstrap.php | 13 + tests/php/integration.suite.yml | 3 + tests/php/integration/AAAMainTest.php | 266 +- tests/php/integration/ACFE/FormTest.php | 87 +- .../integration/Abilities/AbilitiesTest.php | 34 +- .../integration/Admin/AdminNoticesTest.php | 55 +- .../integration/Admin/Events/EventsTest.php | 354 ++- tests/php/integration/Admin/MaxMindDbTest.php | 819 +++++ .../integration/Admin/NotificationsTest.php | 11 + .../Admin/OnboardingWizardTest.php | 504 ++++ .../php/integration/Admin/PluginStatsTest.php | 220 ++ tests/php/integration/Admin/PrivacyTest.php | 52 + .../integration/Admin/SupportModalTest.php | 352 +++ tests/php/integration/Admin/WhatsNewTest.php | 267 +- .../integration/BeaverBuilder/ContactTest.php | 17 + .../integration/BeaverBuilder/LoginTest.php | 48 +- .../Blocksy/NewsletterSubscribeTest.php | 113 + .../php/integration/Blocksy/WaitlistTest.php | 94 + .../BuddyPress/CreateGroupTest.php | 27 +- .../integration/BuddyPress/RegisterTest.php | 19 +- tests/php/integration/CLI/CommandsTest.php | 489 +++ tests/php/integration/CLI/WPCLI.php | 104 + .../integration/CLI/WPCLIExitException.php | 15 + tests/php/integration/CLI/functions.php | 27 + tests/php/integration/CoBlocks/FormTest.php | 74 + .../DelayedScript/DelayedScriptTest.php | 293 ++ .../DownloadManager/DownloadManagerTest.php | 63 + .../integration/EssentialBlocks/FormTest.php | 37 +- tests/php/integration/FluentForm/FormTest.php | 94 +- tests/php/integration/Formidable/FormTest.php | 33 + tests/php/integration/Forminator/FormTest.php | 50 + tests/php/integration/GiveWP/BaseTest.php | 105 +- .../php/integration/GravityForms/FormTest.php | 100 +- tests/php/integration/HCaptchaWPTestCase.php | 27 +- tests/php/integration/Helpers/APITest.php | 194 +- .../Helpers/CloudflareDetectorTest.php | 566 ++++ tests/php/integration/Helpers/DBTest.php | 1 + .../php/integration/Helpers/HCaptchaTest.php | 221 ++ tests/php/integration/Helpers/InstallTest.php | 73 + .../integration/Helpers/PlaygroundTest.php | 578 ++++ tests/php/integration/Jetpack/FormTest.php | 63 +- .../integration/Kadence/AdvancedFormTest.php | 55 + tests/php/integration/Kadence/FormTest.php | 76 +- tests/php/integration/MailPoet/FormTest.php | 70 +- .../integration/Migrations/MigrationsTest.php | 563 +++- tests/php/integration/NF/FieldTest.php | 59 +- tests/php/integration/Otter/FormTest.php | 69 +- .../PaidMembershipsPro/CheckoutTest.php | 16 + .../PasswordProtected/ProtectTest.php | 33 + .../ProtectContent/ProtectContentTest.php | 2 +- tests/php/integration/UM/LoginTest.php | 65 +- tests/php/integration/UninstallFileTest.php | 4 +- .../integration/WC/AddPaymentMethodTest.php | 38 + tests/php/integration/WC/CheckoutTest.php | 124 +- .../php/integration/WPDiscuz/CommentTest.php | 9 +- .../integration/WPDiscuz/SubscribeTest.php | 3 +- tests/php/integration/_bootstrap.php | 27 +- .../integration/function-mocker-bootstrap.php | 127 + tests/php/unit/Admin/OnboardingWizardTest.php | 145 - tests/php/unit/AntiSpam/AntiSpamTest.php | 292 ++ .../php/unit/AntiSpam/DisposableEmailTest.php | 304 +- tests/php/unit/AntiSpam/HoneypotTest.php | 87 + tests/php/unit/AntiSpam/ProviderBaseTest.php | 54 + tests/php/unit/AutoVerify/AutoVerifyTest.php | 37 + tests/php/unit/MainTest.php | 82 + .../Detectors/ACFEDetectorTest.php | 59 +- .../Detectors/AbstractDetectorTest.php | 70 + .../AdvancedNoCaptchaDetectorTest.php | 129 + .../Detectors/CF7RecaptchaDetectorTest.php | 152 + .../GravityFormsRecaptchaDetectorTest.php | 103 + .../Detectors/SimpleTurnstileDetectorTest.php | 145 + .../WPFormsRecaptchaDetectorTest.php | 33 +- .../MigrationWizardControllerTest.php | 676 +++++ tests/php/unit/Settings/EventsPageTest.php | 173 ++ tests/php/unit/Settings/FormsPageTest.php | 208 ++ tests/php/unit/Settings/GeneralTest.php | 104 +- tests/php/unit/Settings/IntegrationsTest.php | 483 ++- tests/php/unit/Settings/ListPageBaseTest.php | 42 +- tests/php/unit/Settings/SettingsTest.php | 43 +- tests/php/unit/Settings/SystemInfoTest.php | 12 +- .../Settings/IntegrationsTestSubject.php | 121 + tests/php/unit/_bootstrap.php | 1 + yarn.lock | 2654 +++++++++-------- 199 files changed, 22728 insertions(+), 2497 deletions(-) create mode 100644 .codeception/_support/FunctionMockerExtension.php create mode 100644 assets/css/support-modal.css create mode 100644 assets/js/hcaptcha-blocksy.js create mode 100644 assets/js/support-modal.js create mode 100644 src/php/Admin/SupportModal.php create mode 100644 tests/js/assets-js-files/admin-kadence-advanced.test.js create mode 100644 tests/js/assets-js-files/admin-mailchimp.test.js create mode 100644 tests/js/assets-js-files/admin-nf.test.js create mode 100644 tests/js/assets-js-files/admin-quform.test.js create mode 100644 tests/js/assets-js-files/coverage-fill.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-auto-verify.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-avada.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-back-in-stock-notifier.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-blocksy.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-brizy.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-customer-reviews.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-divi-email-optin.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-essential-blocks.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-fluentform.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-forminator.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-fst.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-givewp.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-gravity-forms.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-helper.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-icegram-express.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-jetpack.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-kadence-advanced.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-kadence.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-login-signup-popup.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-mailpoet.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-otter.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-passster.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-quform.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-sendinblue.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-simple-download-monitor.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-spectra.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-ultimate-addons.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-users-wp.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-wc-block-checkout.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-woocommerce-paypal-payments-early.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-woocommerce-paypal-payments.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-wpdiscuz-comment.test.js create mode 100644 tests/js/assets-js-files/hcaptcha-wpforms.test.js create mode 100644 tests/js/assets-js-files/support-modal.test.js create mode 100644 tests/php/_bootstrap.php create mode 100644 tests/php/integration/Admin/MaxMindDbTest.php create mode 100644 tests/php/integration/Admin/OnboardingWizardTest.php create mode 100644 tests/php/integration/Admin/PluginStatsTest.php create mode 100644 tests/php/integration/Admin/PrivacyTest.php create mode 100644 tests/php/integration/Admin/SupportModalTest.php create mode 100644 tests/php/integration/CLI/CommandsTest.php create mode 100644 tests/php/integration/CLI/WPCLI.php create mode 100644 tests/php/integration/CLI/WPCLIExitException.php create mode 100644 tests/php/integration/CLI/functions.php create mode 100644 tests/php/integration/Helpers/CloudflareDetectorTest.php create mode 100644 tests/php/integration/Helpers/InstallTest.php create mode 100644 tests/php/integration/Helpers/PlaygroundTest.php create mode 100644 tests/php/integration/function-mocker-bootstrap.php delete mode 100644 tests/php/unit/Admin/OnboardingWizardTest.php create mode 100644 tests/php/unit/AntiSpam/AntiSpamTest.php create mode 100644 tests/php/unit/AntiSpam/HoneypotTest.php create mode 100644 tests/php/unit/AntiSpam/ProviderBaseTest.php create mode 100644 tests/php/unit/MigrationWizard/Detectors/AbstractDetectorTest.php create mode 100644 tests/php/unit/MigrationWizard/Detectors/AdvancedNoCaptchaDetectorTest.php create mode 100644 tests/php/unit/MigrationWizard/Detectors/CF7RecaptchaDetectorTest.php create mode 100644 tests/php/unit/MigrationWizard/Detectors/GravityFormsRecaptchaDetectorTest.php create mode 100644 tests/php/unit/MigrationWizard/Detectors/SimpleTurnstileDetectorTest.php create mode 100644 tests/php/unit/MigrationWizard/MigrationWizardControllerTest.php create mode 100644 tests/php/unit/Stubs/Settings/IntegrationsTestSubject.php diff --git a/.codeception/_support/FunctionMockerExtension.php b/.codeception/_support/FunctionMockerExtension.php new file mode 100644 index 000000000..e9be229d7 --- /dev/null +++ b/.codeception/_support/FunctionMockerExtension.php @@ -0,0 +1,37 @@ + label, +.hcaptcha-support-field > label, +.hcaptcha-support-diagnostics h3 { + display: block; + margin: 0 0 6px; + color: #1d2327; + font-weight: 600; +} + +.hcaptcha-support-request-types label { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + margin: 0; +} + +.hcaptcha-support-field { + margin: 0 0 16px; +} + +.hcaptcha-support-field input[type="text"], +.hcaptcha-support-field select, +.hcaptcha-support-field textarea, +.hcaptcha-support-preview-wrap textarea { + box-sizing: border-box; + width: 100%; + max-width: none; +} + +.hcaptcha-support-field textarea, +.hcaptcha-support-preview-wrap textarea { + resize: vertical; +} + +.hcaptcha-support-preview-wrap textarea { + min-height: 430px; + font-family: Consolas, Monaco, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre; +} + +.hcaptcha-support-type-fields[hidden] { + display: none; +} + +.hcaptcha-support-diagnostics { + margin-top: 20px; +} + +.hcaptcha-support-diagnostics h3 { + font-size: 14px; +} + +.hcaptcha-support-include-system-info { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 12px 0 0; + line-height: 1.4; +} + +.hcaptcha-support-include-system-info input[type="checkbox"] { + margin-top: 1px; +} + +.hcaptcha-support-modal-footer { + padding: 16px 24px 20px; + border-top: 1px solid #dcdcde; + background: #f6f7f7; +} + +.hcaptcha-support-status { + min-height: 20px; + line-height: 1.4; +} + +.hcaptcha-support-status.is-success { + color: #008a20; +} + +.hcaptcha-support-status.is-error { + color: #d63638; +} + +.hcaptcha-support-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: flex-start; + gap: 14px 12px; + width: 100%; +} + +.hcaptcha-support-action { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; +} + +.hcaptcha-support-copy-action .hcaptcha-support-status { + flex-basis: 100%; + margin: 0; +} + +.hcaptcha-support-action-description { + position: absolute; + z-index: 3; + bottom: calc(100% + 8px); + left: 0; + display: none; + box-sizing: border-box; + width: min(320px, 100%); + padding: 8px 10px; + background: #5c6f8a; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); + color: #f0f2f5; +} + +.hcaptcha-support-action-description::after { + content: ''; + position: absolute; + bottom: -6px; + left: 16px; + border-width: 6px 6px 0; + border-style: solid; + border-color: #5c6f8a transparent transparent; +} + +.hcaptcha-support-action:hover .hcaptcha-support-action-description, +.hcaptcha-support-action:focus-within .hcaptcha-support-action-description, +.hcaptcha-support-action.is-description-open .hcaptcha-support-action-description { + display: block; +} + +.hcaptcha-support-action-description .description { + margin: 0; + color: inherit; +} + +.hcaptcha-support-action-description .description + .description { + margin-top: 6px; +} + +.hcaptcha-support-recommended { + display: inline-block; + flex: 0 0 auto; + padding: 2px 7px; + border-radius: 2px; + background: #e5f5fa; + color: #007cba; + font-size: 11px; + font-weight: 600; + line-height: 1.4; +} + +.hcaptcha-support-recommended[hidden] { + display: none; +} + +.hcaptcha-support-action-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 50%; + background: #5c6f8a; + color: #fff; + font-size: 13px; + font-weight: 600; + line-height: 1; + cursor: help; +} + +.hcaptcha-support-action-help:focus { + outline: 2px solid var(--wp-admin-theme-color, #2271b1); + outline-offset: 2px; +} + +.hcaptcha-support-field .required { + color: #d63638; +} + +.hcaptcha-support-field [aria-invalid="true"] { + border-color: #d63638; + box-shadow: 0 0 0 1px #d63638; +} + +@media (max-width: 1000px) { + .hcaptcha-support-actions { + display: flex; + flex-direction: column; + align-items: stretch; + } +} + +@media (max-width: 960px) { + .hcaptcha-support-modal-content { + grid-template-columns: 1fr; + } + + .hcaptcha-support-preview-wrap textarea { + min-height: 260px; + } +} + +@media (max-width: 600px) { + .hcaptcha-support-modal { + padding: 8px; + } + + .hcaptcha-support-modal-box { + width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + } + + .hcaptcha-support-modal-header, + .hcaptcha-support-modal-content, + .hcaptcha-support-modal-footer { + padding-left: 16px; + padding-right: 16px; + } + + .hcaptcha-support-modal-content { + gap: 18px; + } + + .hcaptcha-support-modal-footer { + padding-top: 12px; + padding-bottom: 12px; + } + + .hcaptcha-support-actions { + gap: 10px; + } + + .hcaptcha-support-status { + min-height: 0; + } +} diff --git a/assets/css/whats-new.css b/assets/css/whats-new.css index 99ac9ec99..e8b658059 100644 --- a/assets/css/whats-new.css +++ b/assets/css/whats-new.css @@ -95,6 +95,81 @@ color: #0075ab; } +.hcaptcha-whats-new-version-control { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.hcaptcha-whats-new-version-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin: 5px 0 0 0; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.hcaptcha-whats-new-version-toggle:before { + display: block; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #0075ab; + content: ""; +} + +.hcaptcha-whats-new-version-control.is-open .hcaptcha-whats-new-version-toggle:before { + transform: rotate(180deg); +} + +.hcaptcha-whats-new-version-list { + position: absolute; + z-index: 100002; + top: 100%; + right: 0; + min-width: 110px; + margin: 6px 0 0 0; + padding: 6px 0; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16); + font-size: 14px; + line-height: 1.4; + list-style: none; +} + +.hcaptcha-whats-new-version-list li { + margin: 0; +} + +.hcaptcha-whats-new-version-list a { + display: block; + padding: 6px 12px; + color: #1d2327; + text-align: left; + text-decoration: none; +} + +.hcaptcha-whats-new-version-list a:hover, +.hcaptcha-whats-new-version-list a:focus { + background: #f0f6fc; + color: #0075ab; +} + +.hcaptcha-whats-new-version-list .is-current a { + background: #f0f6fc; + color: #0075ab; + font-weight: 600; +} + .hcaptcha-whats-new-content { margin: 50px 0 0 0; flex: 1 1 auto; diff --git a/assets/js/hcaptcha-acfe.js b/assets/js/hcaptcha-acfe.js index 353f6f6df..9ec35b828 100644 --- a/assets/js/hcaptcha-acfe.js +++ b/assets/js/hcaptcha-acfe.js @@ -1,15 +1,88 @@ -/* global jQuery */ +/* global acf, jQuery */ const hCaptchaACFE = window.hCaptchaACFE || ( function( window, $ ) { const app = { savedOnLoad: null, init() { + app.blockRecaptchaScript(); + + // Disable the native ACFE reCAPTCHA controller before ACF initializes fields. + app.disableRecaptchaField(); + app.removeRecaptchaNodes(); + // Install custom callbacks to sync token into ACFE hidden field. app.installCallbacks(); + // Sync tokens even when the widget was rendered before this integration wrapped params.callback. + document.addEventListener( 'hCaptchaSubmitted', app.submittedHandler ); + // Bind ajaxComplete event to re-bind hCaptcha after AJAX requests. $( document ).on( 'ajaxComplete', app.ajaxCompleteHandler ); + + // Back/forward cache can restore an already rendered native reCAPTCHA widget. + window.addEventListener( 'pageshow', app.pageShowHandler ); + }, + + blockRecaptchaScript() { + if ( typeof $.ajaxPrefilter !== 'function' ) { + return; + } + + $.ajaxPrefilter( ( options, originalOptions, jqXHR ) => { + const url = options.url || originalOptions?.url || ''; + + if ( ! app.isRecaptchaUrl( url ) ) { + return; + } + + jqXHR.abort(); + } ); + }, + + isRecaptchaUrl( url ) { + const recaptchaUrl = String( url ).toLowerCase(); + + return recaptchaUrl.includes( 'google.com/recaptcha' ) || recaptchaUrl.includes( 'recaptcha.net/recaptcha' ); + }, + + disableRecaptchaField() { + if ( typeof acf === 'undefined' || typeof acf.getFieldType !== 'function' ) { + return; + } + + const reCaptchaField = acf.getFieldType( 'acfe_recaptcha' ); + + if ( ! reCaptchaField || ! reCaptchaField.prototype ) { + return; + } + + reCaptchaField.prototype.initialize = function() {}; + reCaptchaField.prototype.render = function() {}; + reCaptchaField.prototype.reset = function() {}; + reCaptchaField.prototype.onInvalidField = function() {}; + }, + + removeRecaptchaNodes() { + document.querySelectorAll( '.acfe-field-recaptcha iframe' ).forEach( ( el ) => { + const src = el.getAttribute( 'src' ) || ''; + const title = el.getAttribute( 'title' ) || ''; + const isRecaptcha = ( src + title ).toLowerCase().includes( 'recaptcha' ); + + if ( ! isRecaptcha ) { + return; + } + + const widget = el.closest( '.g-recaptcha' ) || el.parentElement; + + if ( widget ) { + widget.remove(); + } + } ); + + document.querySelectorAll( '.acfe-field-recaptcha .g-recaptcha, .acfe-field-recaptcha .grecaptcha-badge' ).forEach( ( el ) => { + el.remove(); + } ); }, installCallbacks() { @@ -30,7 +103,14 @@ const hCaptchaACFE = window.hCaptchaACFE || ( function( window, $ ) { updateHidden( response, callback ) { [ ...document.querySelectorAll( '.acfe-field-recaptcha input[id^="acf-field_"]' ) ].forEach( ( el ) => { - el.value = response; + if ( typeof acf !== 'undefined' && typeof acf.val === 'function' ) { + acf.val( $( el ), response, true ); + } else { + el.value = response; + } + + el.dispatchEvent( new Event( 'input', { bubbles: true } ) ); + el.dispatchEvent( new Event( 'change', { bubbles: true } ) ); } ); if ( callback ) { @@ -38,6 +118,10 @@ const hCaptchaACFE = window.hCaptchaACFE || ( function( window, $ ) { } }, + submittedHandler( event ) { + app.updateHidden( event.detail?.token || '' ); + }, + onLoadWrapper() { window.hCaptchaOnLoad = app.savedOnLoad; window.hCaptchaOnLoad(); @@ -49,6 +133,12 @@ const hCaptchaACFE = window.hCaptchaACFE || ( function( window, $ ) { window.hCaptchaBindEvents(); } }, + + pageShowHandler() { + app.disableRecaptchaField(); + app.removeRecaptchaNodes(); + app.ajaxCompleteHandler(); + }, }; return app; diff --git a/assets/js/hcaptcha-blocksy.js b/assets/js/hcaptcha-blocksy.js new file mode 100644 index 000000000..f339ff0d5 --- /dev/null +++ b/assets/js/hcaptcha-blocksy.js @@ -0,0 +1,54 @@ +import { helper } from './hcaptcha-helper.js'; + +const hCaptchaBlocksy = window.hCaptchaBlocksy || ( function( window ) { + const actions = [ + 'blc_newsletter_subscribe_process_ajax_subscribe', + 'blc_subcribe_to_waitlist', + ]; + + const app = { + init() { + helper.installFetchEvents(); + window.addEventListener( 'hCaptchaFetch:complete', app.fetchComplete ); + }, + + fetchComplete( event ) { + const config = event?.detail?.args?.[ 1 ] ?? {}; + const body = config.body; + + if ( ! actions.includes( app.getAction( body ) ) ) { + return; + } + + if ( typeof window.hCaptchaBindEvents === 'function' ) { + window.hCaptchaBindEvents(); + } + + if ( typeof window.hCaptchaFST?.getToken === 'function' ) { + window.hCaptchaFST.getToken(); + } + }, + + getAction( body ) { + if ( body instanceof FormData || body instanceof URLSearchParams ) { + return body.get( 'action' ) ?? ''; + } + + if ( typeof body === 'object' && body !== null ) { + return body.action ?? ''; + } + + if ( typeof body !== 'string' ) { + return ''; + } + + return new URLSearchParams( body ).get( 'action' ) ?? ''; + }, + }; + + return app; +}( window ) ); + +window.hCaptchaBlocksy = hCaptchaBlocksy; + +hCaptchaBlocksy.init(); diff --git a/assets/js/hcaptcha-fst.js b/assets/js/hcaptcha-fst.js index f2ed9abf2..8e0a9807b 100644 --- a/assets/js/hcaptcha-fst.js +++ b/assets/js/hcaptcha-fst.js @@ -19,6 +19,8 @@ const fst = window.hCaptchaFST || ( function( document ) { */ const app = { init() { + app.getToken(); + document.addEventListener( 'hCaptchaAfterBindEvents', function() { app.getToken(); } ); diff --git a/assets/js/hcaptcha-givewp.js b/assets/js/hcaptcha-givewp.js index 55dc2e46e..2aba7e152 100644 --- a/assets/js/hcaptcha-givewp.js +++ b/assets/js/hcaptcha-givewp.js @@ -148,7 +148,7 @@ const hCaptchaGiveWP = window.hCaptchaGiveWP || ( function( window ) { return; } - const submitSection = submitButton ? submitButton.closest( 'section' ) : null; + const submitSection = submitButton.closest( 'section' ); // On multistep form, the `submit` button is not in the section. const submitElement = submitSection ?? submitButton; diff --git a/assets/js/hcaptcha-mailpoet.js b/assets/js/hcaptcha-mailpoet.js index 1c437b681..5c8133fd4 100644 --- a/assets/js/hcaptcha-mailpoet.js +++ b/assets/js/hcaptcha-mailpoet.js @@ -22,16 +22,32 @@ const hCaptchaMailPoet = window.hCaptchaMailPoet || ( function( window, $ ) { return; } - // eslint-disable-next-line @wordpress/no-global-active-element - const eventTarget = options.context || document.activeElement; - const $form = $( eventTarget.closest( 'form' ) ); - // Field names. const responseName = 'h-captcha-response'; const widgetName = 'hcaptcha-widget-id'; const nonceName = 'hcaptcha_mailpoet_nonce'; const tokenName = 'hcap_fst_token'; const sigName = 'hcap_hp_sig'; + const formId = urlParams.get( 'data[form_id]' ); + let $form = $(); + + if ( formId ) { + $form = $( 'input[name="data[form_id]"]' ).filter( function() { + return $( this ).val() === formId; + } ).closest( 'form' ); + } + + if ( ! $form.length ) { + // eslint-disable-next-line @wordpress/no-global-active-element + const eventTarget = options.context || document.activeElement; + const form = eventTarget?.closest?.( 'form' ); + + $form = form ? $( form ) : $(); + } + + if ( ! $form.length ) { + return; + } const response = $form.find( `[name="${ responseName }"]` ).val() ?? ''; const widget = $form.find( `[name="${ widgetName }"]` ).val() ?? ''; diff --git a/assets/js/hcaptcha-otter.js b/assets/js/hcaptcha-otter.js index 5b3a34d9d..968aa0c12 100644 --- a/assets/js/hcaptcha-otter.js +++ b/assets/js/hcaptcha-otter.js @@ -57,7 +57,7 @@ const hCaptchaOtter = window.hCaptchaOtter || ( function( window ) { return; } - const form = document.getElementById( formId ); + const form = app.getForm( formId ); if ( ! form ) { return; @@ -107,6 +107,11 @@ const hCaptchaOtter = window.hCaptchaOtter || ( function( window ) { event.detail.args = args; }, + getForm( formId ) { + return document.getElementById( formId ) || + document.getElementById( `wp-block-themeisle-blocks-form-${ formId }` ); + }, + fetchComplete( event ) { const args = event?.detail?.args ?? []; const resource = args[ 0 ]; diff --git a/assets/js/support-modal.js b/assets/js/support-modal.js new file mode 100644 index 000000000..3d4de5a2a --- /dev/null +++ b/assets/js/support-modal.js @@ -0,0 +1,391 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +/* global HCaptchaSupportModalObject */ + +/** + * hCaptcha support modal logic. + */ +const supportModal = () => { + const modal = document.getElementById( 'hcaptcha-support-modal' ); + const openButton = document.querySelector( '.hcaptcha-help-button' ); + + if ( ! modal || ! openButton || ! window.HCaptchaSupportModalObject ) { + return; + } + + const config = HCaptchaSupportModalObject; + const strings = config.strings || {}; + const reportStrings = strings.report || {}; + const systemInfo = config.systemInfo || ''; + const summaryField = document.getElementById( 'hcaptcha-support-summary' ); + const detailsField = document.getElementById( 'hcaptcha-support-details' ); + const areaField = document.getElementById( 'hcaptcha-support-area' ); + const reportField = document.getElementById( 'hcaptcha-support-report' ); + const status = document.getElementById( 'hcaptcha-support-status' ); + const wordpressButton = modal.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ); + const includeSystemInfoField = document.getElementById( 'hcaptcha-support-include-system-info' ); + let opener = null; + + const fieldIds = [ + 'steps', + 'expected', + 'actual', + 'problem', + 'solution', + 'alternatives', + 'configure', + 'tried', + ]; + + const getField = ( id ) => document.getElementById( `hcaptcha-support-${ id }` ); + + const getValue = ( field ) => field ? field.value.trim() : ''; + + const getType = () => { + const checked = modal.querySelector( 'input[name="hcaptcha-support-type"]:checked' ); + + return checked ? checked.value : 'bug'; + }; + + const isVisible = ( element ) => { + if ( element.hidden || element.closest( '[hidden]' ) ) { + return false; + } + + const style = window.getComputedStyle( element ); + + return style.display !== 'none' && style.visibility !== 'hidden'; + }; + + const getFocusable = () => Array.from( + modal.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ).filter( isVisible ); + + const setWordPressReady = ( ready ) => { + if ( wordpressButton ) { + wordpressButton.disabled = ! ready; + } + }; + + const showStatus = ( message = '', type = '' ) => { + if ( ! status ) { + return; + } + + status.textContent = message; + status.className = [ + 'hcaptcha-support-status', + type ? `is-${ type }` : '', + ].filter( Boolean ).join( ' ' ); + }; + + const makeSection = ( title, content ) => `## ${ title || '' }\n\n${ content || strings.emptyValue || '' }\n`; + + const makeOptionalSection = ( title, content ) => content ? makeSection( title, content ) : ''; + + const joinSections = ( sections ) => sections.filter( Boolean ).join( '\n' ).trim(); + + const makeDiagnostics = () => { + if ( ! includeSystemInfoField || ! includeSystemInfoField.checked ) { + return ''; + } + + return systemInfo; + }; + + const buildBugReport = () => joinSections( [ + makeSection( reportStrings.summary, getValue( summaryField ) ), + makeSection( reportStrings.affectedArea, getValue( areaField ) ), + makeSection( reportStrings.steps, getValue( getField( 'steps' ) ) ), + makeSection( reportStrings.expected, getValue( getField( 'expected' ) ) ), + makeSection( reportStrings.actual, getValue( getField( 'actual' ) ) ), + makeSection( reportStrings.additional, getValue( detailsField ) ), + makeOptionalSection( reportStrings.diagnostics, makeDiagnostics() ), + ] ); + + const buildFeatureReport = () => joinSections( [ + makeSection( reportStrings.feature, getValue( summaryField ) ), + makeSection( reportStrings.affectedArea, getValue( areaField ) ), + makeSection( reportStrings.problem, getValue( getField( 'problem' ) ) ), + makeSection( reportStrings.solution, getValue( getField( 'solution' ) ) ), + makeSection( reportStrings.alternatives, getValue( getField( 'alternatives' ) ) ), + makeSection( reportStrings.additional, getValue( detailsField ) ), + makeOptionalSection( reportStrings.diagnostics, makeDiagnostics() ), + ] ); + + const buildSupportReport = () => joinSections( [ + makeSection( reportStrings.question, getValue( summaryField ) ), + makeSection( reportStrings.affectedArea, getValue( areaField ) ), + makeSection( reportStrings.configure, getValue( getField( 'configure' ) ) ), + makeSection( reportStrings.tried, getValue( getField( 'tried' ) ) ), + makeSection( reportStrings.additional, getValue( detailsField ) ), + makeOptionalSection( reportStrings.diagnostics, makeDiagnostics() ), + ] ); + + const buildReport = () => { + switch ( getType() ) { + case 'feature': + return buildFeatureReport(); + case 'support': + return buildSupportReport(); + case 'bug': + default: + return buildBugReport(); + } + }; + + const updateTypeFields = () => { + const type = getType(); + + modal.querySelectorAll( '[data-hcaptcha-support-fields]' ).forEach( ( group ) => { + group.hidden = group.dataset.hcaptchaSupportFields !== type; + } ); + + modal.querySelectorAll( '.hcaptcha-support-action' ).forEach( ( action ) => { + const target = action.dataset.hcaptchaSupportAction; + const recommended = action.querySelector( '.hcaptcha-support-recommended' ); + + if ( ! recommended ) { + return; + } + + recommended.hidden = target === 'wordpress' ? type !== 'support' : type === 'support'; + } ); + }; + + const closeActionTips = ( except = null ) => { + modal.querySelectorAll( '.hcaptcha-support-action.is-description-open' ).forEach( ( action ) => { + if ( action === except ) { + return; + } + + action.classList.remove( 'is-description-open' ); + + const actionHelp = action.querySelector( '.hcaptcha-support-action-help' ); + + if ( actionHelp ) { + actionHelp.setAttribute( 'aria-expanded', 'false' ); + } + } ); + }; + + const toggleActionTip = ( button ) => { + const action = button.closest( '.hcaptcha-support-action' ); + + if ( ! action ) { + return; + } + + const isOpen = action.classList.contains( 'is-description-open' ); + + closeActionTips( action ); + action.classList.toggle( 'is-description-open', ! isOpen ); + button.setAttribute( 'aria-expanded', String( ! isOpen ) ); + }; + + const updateReport = () => { + closeActionTips(); + updateTypeFields(); + setWordPressReady( false ); + showStatus(); + + if ( reportField ) { + reportField.value = buildReport(); + } + }; + + const validateSummary = () => { + if ( getValue( summaryField ) ) { + summaryField.removeAttribute( 'aria-invalid' ); + + return true; + } + + summaryField.setAttribute( 'aria-invalid', 'true' ); + showStatus( strings.summaryRequired || '', 'error' ); + summaryField.focus(); + + return false; + }; + + const copyText = async ( text ) => { + if ( ! window.navigator.clipboard || ! window.navigator.clipboard.writeText ) { + throw new Error( 'clipboard' ); + } + + return window.navigator.clipboard.writeText( text ); + }; + + const copyReport = async () => { + updateReport(); + + try { + await copyText( reportField.value ); + setWordPressReady( true ); + showStatus( strings.copySuccess || '', 'success' ); + + return true; + } catch ( error ) { + void error; + setWordPressReady( true ); + showStatus( strings.copyError || '', 'error' ); + reportField.focus(); + reportField.select(); + + return false; + } + }; + + const openExternal = ( url ) => { + const external = window.open( url, '_blank' ); + + if ( ! external ) { + showStatus( strings.openFailed || '', 'error' ); + + return; + } + + external.opener = null; + }; + + const continueGithub = () => { + if ( ! validateSummary() ) { + return; + } + + updateReport(); + + const url = new URL( config.githubIssueUrl ); + + url.searchParams.set( 'title', getValue( summaryField ) ); + url.searchParams.set( 'body', reportField.value ); + openExternal( url.toString() ); + }; + + const continueWordPress = () => { + if ( ! validateSummary() ) { + return; + } + + openExternal( config.wordpressSupportUrl ); + }; + + const openModal = () => { + closeActionTips(); + opener = modal.ownerDocument.activeElement; + modal.hidden = false; + document.body.classList.add( 'hcaptcha-support-modal-open' ); + showStatus(); + updateReport(); + summaryField.focus(); + }; + + const closeModal = () => { + closeActionTips(); + modal.hidden = true; + document.body.classList.remove( 'hcaptcha-support-modal-open' ); + + if ( opener && opener.focus ) { + opener.focus(); + } + }; + + const trapFocus = ( event ) => { + if ( event.key !== 'Tab' ) { + return; + } + + const focusable = getFocusable(); + + if ( ! focusable.length ) { + return; + } + + const first = focusable[ 0 ]; + const last = focusable[ focusable.length - 1 ]; + + if ( event.shiftKey && modal.ownerDocument.activeElement === first ) { + event.preventDefault(); + last.focus(); + } else if ( ! event.shiftKey && modal.ownerDocument.activeElement === last ) { + event.preventDefault(); + first.focus(); + } + }; + + openButton.addEventListener( 'click', openModal ); + + modal.addEventListener( 'click', ( event ) => { + if ( ! event.target.closest ) { + return; + } + + if ( event.target.closest( '[data-hcaptcha-support-close]' ) ) { + closeModal(); + + return; + } + + const actionHelp = event.target.closest( '.hcaptcha-support-action-help' ); + + if ( actionHelp ) { + event.preventDefault(); + toggleActionTip( actionHelp ); + + return; + } + + if ( ! event.target.closest( '.hcaptcha-support-action-description' ) ) { + closeActionTips(); + } + + if ( event.target.closest( '[data-hcaptcha-support-copy]' ) ) { + copyReport(); + } + + const continueButton = event.target.closest( '[data-hcaptcha-support-continue]' ); + + if ( ! continueButton ) { + return; + } + + if ( continueButton.dataset.hcaptchaSupportContinue === 'github' ) { + continueGithub(); + } else { + continueWordPress(); + } + } ); + + modal.addEventListener( 'input', updateReport ); + modal.addEventListener( 'change', updateReport ); + + document.addEventListener( 'keydown', ( event ) => { + if ( modal.hidden ) { + return; + } + + if ( event.key === 'Escape' ) { + closeModal(); + + return; + } + + trapFocus( event ); + } ); + + fieldIds.forEach( ( id ) => { + const field = getField( id ); + + if ( field ) { + field.addEventListener( 'input', updateReport ); + } + } ); + + updateReport(); +}; + +window.hCaptchaSupportModal = supportModal; + +document.addEventListener( 'DOMContentLoaded', supportModal ); diff --git a/assets/js/whats-new.js b/assets/js/whats-new.js index e2261d36a..217bbe49f 100644 --- a/assets/js/whats-new.js +++ b/assets/js/whats-new.js @@ -2,10 +2,11 @@ /** * @typedef {Object} HCaptchaWhatsNewObject - * @property {string} ajaxUrl The URL to send AJAX requests to. - * @property {string} markShownAction The action to mark the popup as shown. - * @property {string} markShownNonce The nonce for the mark the popup as shown action. - * @property {string} whatsNewParam The GET parameter for forcing What's New popup. + * @property {string} ajaxUrl The URL to send AJAX requests to. + * @property {string} markShownAction The action to mark the popup as shown. + * @property {string} markShownNonce The nonce for the mark the popup as shown action. + * @property {string} renderPopupAction The action to render a selected What's New popup. + * @property {string} renderPopupNonce The nonce for the render a selected What's New popup action. */ /** @@ -19,14 +20,18 @@ const whatsNew = ( $ ) => { * @property {Function} fadeOut Function to fade out the modal. */ + function getModal() { + return $( '#hcaptcha-whats-new-modal' ); + } + /** @type {jQuery} */ - const $modal = $( '#hcaptcha-whats-new-modal' ); + const $initialModal = getModal(); - if ( ! $modal.length ) { + if ( ! $initialModal.length ) { return; } - if ( $modal.css( 'display' ) === 'flex' ) { + if ( $initialModal.css( 'display' ) === 'flex' ) { document.body.style.overflow = 'hidden'; } @@ -36,25 +41,13 @@ const whatsNew = ( $ ) => { } function closePopup() { - $modal.fadeOut( 200, function() { + getModal().fadeOut( 200, function() { document.body.style.overflow = ''; $( this ).css( 'display', 'none' ); } ); } function markShown() { - // If the page was opened with the GET parameter `whats_new`, skip marking as shown. - // Return a resolved promise so callers can still chain `.always()` safely. - try { - const params = new URLSearchParams( window.location.search ); - - if ( params.has( HCaptchaWhatsNewObject.whatsNewParam ) ) { - return jQuery.Deferred().resolve().promise(); - } - } catch { - // In environments without URLSearchParams, silently ignore and proceed. - } - const data = { action: HCaptchaWhatsNewObject.markShownAction, nonce: HCaptchaWhatsNewObject.markShownNonce, @@ -68,6 +61,97 @@ const whatsNew = ( $ ) => { } ); } + function closeVersionDropdown() { + const $control = getModal().find( '.hcaptcha-whats-new-version-control' ); + + $control.removeClass( 'is-open' ); + $control.find( '.hcaptcha-whats-new-version-toggle' ).attr( 'aria-expanded', 'false' ); + $control.find( '.hcaptcha-whats-new-version-list' ).attr( 'hidden', 'hidden' ); + } + + function toggleVersionDropdown() { + const $control = getModal().find( '.hcaptcha-whats-new-version-control' ); + const isOpen = $control.hasClass( 'is-open' ); + const $list = $control.find( '.hcaptcha-whats-new-version-list' ); + + $control.toggleClass( 'is-open', ! isOpen ); + $control.find( '.hcaptcha-whats-new-version-toggle' ).attr( 'aria-expanded', String( ! isOpen ) ); + + if ( isOpen ) { + $list.attr( 'hidden', 'hidden' ); + return; + } + + $list.removeAttr( 'hidden' ); + } + + function replacePopup( html ) { + const $response = $( '
' ).append( $.parseHTML( html ) ); + const $newModal = $response.find( '#hcaptcha-whats-new-modal' ); + if ( ! $newModal.length ) { + return false; + } + + const $newLightbox = $response.find( '#hcaptcha-lightbox-modal' ); + const $currentLightbox = $( '#hcaptcha-lightbox-modal' ); + + getModal().replaceWith( $newModal ); + + if ( $newLightbox.length ) { + if ( $currentLightbox.length ) { + $currentLightbox.replaceWith( $newLightbox ); + } else { + $newModal.after( $newLightbox ); + } + } + + document.body.style.overflow = 'hidden'; + getModal().hide().fadeIn( 200 ).show().css( 'display', 'flex' ); + + // Some hack. Without it, a background filter is not applied. + getModal().find( '.hcaptcha-whats-new-modal-bg' ).hide().show( 200 ); + + return true; + } + + function restorePopup( $modal ) { + document.body.style.overflow = 'hidden'; + $modal.show().css( 'display', 'flex' ); + } + + function loadPopup( $link ) { + const version = $link.data( 'version' ); + + if ( ! version ) { + return; + } + + const $currentModal = getModal(); + + closeVersionDropdown(); + + $currentModal.fadeOut( 200, function() { + $.post( { + url: HCaptchaWhatsNewObject.ajaxUrl, + data: { + action: HCaptchaWhatsNewObject.renderPopupAction, + nonce: HCaptchaWhatsNewObject.renderPopupNonce, + version, + }, + } ) + .done( function( response ) { + if ( response && response.success && response.data && replacePopup( response.data.html ) ) { + return; + } + + restorePopup( $currentModal ); + } ) + .fail( function() { + restorePopup( $currentModal ); + } ); + } ); + } + $( document ).on( 'click', '#hcaptcha-whats-new-close, .hcaptcha-whats-new-modal-bg', function() { done(); } ); @@ -77,10 +161,33 @@ const whatsNew = ( $ ) => { return; } + closeVersionDropdown(); done(); } ); - $modal.on( 'click', '.hcaptcha-whats-new-button a', function( e ) { + $( document ).on( 'click', '#hcaptcha-whats-new-modal .hcaptcha-whats-new-version-toggle', function( e ) { + e.preventDefault(); + e.stopPropagation(); + + toggleVersionDropdown(); + } ); + + $( document ).on( 'click', '#hcaptcha-whats-new-modal .hcaptcha-whats-new-version-list a', function( e ) { + e.preventDefault(); + e.stopPropagation(); + + loadPopup( $( this ) ); + } ); + + $( document ).on( 'click', function( e ) { + if ( $( e.target ).closest( '.hcaptcha-whats-new-version-control' ).length ) { + return; + } + + closeVersionDropdown(); + } ); + + $( document ).on( 'click', '#hcaptcha-whats-new-modal .hcaptcha-whats-new-button a', function( e ) { e.preventDefault(); e.stopImmediatePropagation(); @@ -98,10 +205,10 @@ const whatsNew = ( $ ) => { e.preventDefault(); document.body.style.overflow = 'hidden'; - $modal.fadeIn( 200 ).show().css( 'display', 'flex' ); + getModal().fadeIn( 200 ).show().css( 'display', 'flex' ); // Some hack. Without it, a background filter is not applied. - $modal.find( '.hcaptcha-whats-new-modal-bg' ).hide().show( 200 ); + getModal().find( '.hcaptcha-whats-new-modal-bg' ).hide().show( 200 ); } ); }; diff --git a/changelog.txt b/changelog.txt index bb510ffb9..5d03112a4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,19 @@ += 5.1.0 = +* Added version switching to the What's New popup. +* Added a Help button on hCaptcha admin pages to generate support reports for GitHub or WordPress.org, with optional System Info included. +* Added hcap_delay_api_event filter for delayed loading of the hCaptcha API upon user interaction. +* Hardened form verification for 22 integrations. +* Fixed hcap_delay_api_selector filter for delayed loading of hCaptcha in the Jetpack contact forms. +* Fixed hCaptcha auto-insertion for Jetpack block contact forms that render the Submit button with core Button block markup. +* Fixed hCaptcha auto-insertion for WooCommerce Checkout blocks when the Return to Cart link is enabled. +* Fixed manually added hCaptcha shortcodes inside Jetpack contact forms to use the proper form signature. +* Fixed Events statistics table indexes for MariaDB/MyISAM databases with a 1000-byte key length limit. +* Fixed System Info migration entries to show that older migrations were not required instead of displaying the Unix epoch date. +* Fixed hCaptcha token refresh for Blocksy newsletter and waitlist forms after failed submissions. +* Fixed ACF Extended Forms integration to prevent reCAPTCHA from loading when hCaptcha is used to avoid submission errors. +* Fixed hCaptcha verification for upgraded GiveWP donation forms. +* Fixed FST token replay errors after submitting GiveWP forms without completing hCaptcha. + = 5.0.1 = * Fixed Elementor Pro Forms validation when the optional Form ID is empty or differs from the Elementor widget ID. * Fixed Events statistics table handling to avoid runtime table-existence checks and recreate the table during activation or maintenance when needed. diff --git a/codeception.yml b/codeception.yml index 46fdf040c..0a71e7788 100644 --- a/codeception.yml +++ b/codeception.yml @@ -1,3 +1,4 @@ +bootstrap: _bootstrap.php paths: tests: tests/php output: .codeception/_output diff --git a/composer.json b/composer.json index 5764d9ea7..b54f5d346 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "codeception/module-db": "1.2.0 - 3.2.2", "codeception/module-webdriver": "1.4.1 - 3.2.2", "lucatume/function-mocker": "^2.0.0", - "lucatume/wp-browser": "3.7.16 - 4.5.15", + "lucatume/wp-browser": "3.7.19 - 4.5.15", "squizlabs/php_codesniffer": "^3.13.5", "phpcompatibility/php-compatibility": "^9.3.5", "phpcompatibility/phpcompatibility-wp": "^2.1.8", @@ -58,9 +58,6 @@ }, "classmap": [ "vendors" - ], - "exclude-from-classmap": [ - "src/php/Divi/WPTestCaseStub.php" ] }, "autoload-dev": { diff --git a/hcaptcha.php b/hcaptcha.php index c37e20e6d..6b114d917 100644 --- a/hcaptcha.php +++ b/hcaptcha.php @@ -10,7 +10,7 @@ * Plugin Name: hCaptcha for WP * Plugin URI: https://www.hcaptcha.com/ * Description: hCaptcha keeps out bots and spam while putting privacy first. It is a drop-in replacement for reCAPTCHA. - * Version: 5.0.1 + * Version: 5.1.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: hCaptcha @@ -21,7 +21,7 @@ * Domain Path: /languages/ * * WC requires at least: 3.0 - * WC tested up to: 10.6 + * WC tested up to: 10.9 */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort @@ -39,7 +39,7 @@ /** * Plugin version. */ -const HCAPTCHA_VERSION = '5.0.1'; +const HCAPTCHA_VERSION = '5.1.0'; /** * Path to the plugin dir. diff --git a/package.json b/package.json index ba2e9c317..0a7a2672c 100644 --- a/package.json +++ b/package.json @@ -38,49 +38,49 @@ "node": ">=24.15.0", "npm": ">=11.12.1" }, - "packageManager": "yarn@4.15.0", + "packageManager": "yarn@4.17.0", "dependencies": { - "@wordpress/hooks": "^4.45.0" + "@wordpress/hooks": "^4.49.0" }, "devDependencies": { - "@babel/core": "^7.29.0", - "@babel/preset-env": "^7.29.5", - "@inrupt/jest-jsdom-polyfills": "^4.1.1", - "@wordpress/eslint-plugin": "^25.1.0", + "@babel/core": "^7.29.7", + "@babel/preset-env": "^7.29.7", + "@inrupt/jest-jsdom-polyfills": "^4.1.4", + "@wordpress/eslint-plugin": "^25.5.0", "babel-loader": "^10.1.1", "css-loader": "^7.1.4", "css-minimizer-webpack-plugin": "^8.0.0", - "eslint": "^10.3.0", + "eslint": "^10.6.0", "glob": "^13.0.6", - "globals": "^17.6.0", + "globals": "^17.7.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "jest-fetch-mock": "^3.0.3", "jquery": "^3.7.1", "mini-css-extract-plugin": "^2.10.2", - "prettier": "^3.8.3", - "terser-webpack-plugin": "^5.6.0", + "prettier": "^3.8.5", + "terser-webpack-plugin": "^5.6.1", "typescript": "^6.0.3", - "webpack": "^5.106.2", - "webpack-cli": "^7.0.2", + "webpack": "^5.108.1", + "webpack-cli": "^7.1.0", "webpack-remove-empty-scripts": "^1.1.1" }, "resolutions": { - "@babel/plugin-transform-modules-systemjs": "^7.29.4", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", "cross-spawn": "^7.0.6", "fast-uri": "^3.1.2", "flatted": "^3.4.2", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "micromatch": "^4.0.8", "minimatch@npm:^3": "^3.1.5", "minimatch@npm:9.0.3": "^9.0.9", - "minimatch@npm:^10": "^10.2.4", - "picomatch@npm:^2": "^2.3.2", - "picomatch@npm:^4": "^4.0.4", - "qs": "^6.15.2", - "serialize-javascript": "^7.0.5", + "minimatch@npm:^10": "^10.2.5", + "picomatch@npm:^2": "^2.3.14", + "picomatch@npm:^4": "^4.0.5", + "qs": "^6.15.3", + "serialize-javascript": "^7.0.6", "svgo": "^4.0.1", - "tar": "^7.5.13", - "undici": "^7.24.2" + "tar": "^7.5.17", + "undici": "^7.28.0" } } diff --git a/readme.txt b/readme.txt index 6721f0f68..c54809528 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: captcha, hcaptcha, recaptcha, antispam, spam Requires at least: 6.0 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 5.0.1 +Stable tag: 5.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -153,15 +153,15 @@ curl --globoff -u "USER:APP_PASSWORD" \ The response includes: * overall metrics (total requests, failure rate) -* dominant attack signals +* confidence and top error vectors * breakdown by error type and form source * a list of top offenders (if present) Example (simplified): ` { - "metrics": { "total": 353, "failed": 215 }, - "signals": { "attack_likelihood": "high" }, + "metrics": { "total": 353, "failed": 215, "fail_rate": "0.61" }, + "signals": { "confidence": "high", "top_vectors": ["empty", "spam"] }, "breakdown": { "errors": { "empty": 160, "spam": 16 }, "offenders": [ @@ -691,6 +691,7 @@ add_filter( 'hcap_delay_api', 'my_hcap_delay_api' ); To load the hCaptcha API script only when a WordPress comment form is visible, you can use the followign filter: +` /** * Filters delay API selector. * @@ -718,6 +719,36 @@ add_filter( 'hcap_delay_api_selector', static function ( $delay_api_selector ) { return implode( ', ', $selectors ); } ); +` + += How can I delay the hCaptcha API script until a custom event? = + +Developers can use the `hcap_delay_api_event` filter to opt into custom event-based API loading for specific integrations. + +When the filter returns a non-empty event name, hCaptcha waits for `hCaptchaBeforeAPI`, then listens for that custom event on `document`. The default delay timer and built-in user interaction listeners are skipped. Your integration must dispatch the event when it is ready to load the hCaptcha API. + +Scope this filter carefully to only the pages or forms where you also dispatch the event. + +` +/** + * Filters the custom browser event name used to load the hCaptcha API script. + * + * @param string|mixed $delay_api_event Custom browser event name. + */ +add_filter( + 'hcap_delay_api_event', + static function ( $delay_api_event ): string { + if ( ! is_singular() || ! has_block( 'jetpack/contact-form', get_queried_object() ) ) { + return (string) $delay_api_event; + } + + return 'hcap-load-api'; + } +); + +// Later, when the form is interacted with: +// document.dispatchEvent( new CustomEvent( 'hcap-load-api' ) ); +` = How to set hCaptcha language programmatically? = @@ -989,6 +1020,22 @@ Instructions for popular native integrations are below: == Changelog == += 5.1.0 = +* Added version switching to the What's New popup. +* Added a Help button on hCaptcha admin pages to generate support reports for GitHub or WordPress.org, with optional System Info included. +* Added hcap_delay_api_event filter for delayed loading of the hCaptcha API upon user interaction. +* Hardened form verification for 22 integrations. +* Fixed hcap_delay_api_selector filter for delayed loading of hCaptcha in the Jetpack contact forms. +* Fixed hCaptcha auto-insertion for Jetpack block contact forms that render the Submit button with core Button block markup. +* Fixed hCaptcha auto-insertion for WooCommerce Checkout blocks when the Return to Cart link is enabled. +* Fixed manually added hCaptcha shortcodes inside Jetpack contact forms to use the proper form signature. +* Fixed Events statistics table indexes for MariaDB/MyISAM databases with a 1000-byte key length limit. +* Fixed System Info migration entries to show that older migrations were not required instead of displaying the Unix epoch date. +* Fixed hCaptcha token refresh for Blocksy newsletter and waitlist forms after failed submissions. +* Fixed ACF Extended Forms integration to prevent reCAPTCHA from loading when hCaptcha is used to avoid submission errors. +* Fixed hCaptcha verification for upgraded GiveWP donation forms. +* Fixed FST token replay errors after submitting GiveWP forms without completing hCaptcha. + = 5.0.1 = * Fixed Elementor Pro Forms validation when the optional Form ID is empty or differs from the Elementor widget ID. * Fixed Events statistics table handling to avoid runtime table-existence checks and recreate the table during activation or maintenance when needed. @@ -1007,460 +1054,4 @@ Instructions for popular native integrations are below: * Fixed an issue where a What's New modal action could scroll the current Integrations page before opening the target integration in a new tab. * Fixed errors when resubmitting Essential Addons login and registration forms. -= 4.26.0 = -* Added hcap_delay_api_selector filter to delay Elementor Pro, Contact Form 7, Fluent Forms, Ninja Forms, Customer Reviews, and Icegram Express scripts loading based on selector. -* Added Migration Wizard for Fluent Forms, Forminator, Ninja Forms, MailPoet, CoBlocks, Formidable Forms, Otter, ACFE, Download Manager, GiveWP, Paid Membership Pro, and Brevo to help migrate from Google reCAPTCHA and Cloudflare Turnstile to hCaptcha. -* Added Live Preview for a new Contact Form 7 form. -* Removed Cookies and Content Security Policy plugin integration as it is no longer needed — hCaptcha support is now built into the plugin. -* Improved UX with saving credentials. -* Improved UX with Anti-Spam Indicators. -* Fixed running Elementor Pro, Contact Form 7, Fluent Forms, Ninja Forms, Customer Reviews, and Icegram Express scripts. Now on pages containing relevant form only. -* Fixed multiple Ajax calls for the Anti-Spam Token under some conditions. -* Fixed console error with Fluent Forms. -* Fixed multiple Ajax calls and PHP notices with regular Fluent Forms. -* Fixed notice on pages not containing Ninja Form. -* Fixed floating JavaScript Anti-Spam error with Ninja Form. -* Fixed replacement of any CAPTCHA with hCaptcha in the Kadence Form and Kadence Advanced Form. -* Fixed ACFE form recaptcha validation in admin. -* Fixed hCaptcha verification for the second and subsequent Divi Contact Form instances on the same page. -* Fixed PHP notice with Kadence Form. - -= 4.25.0 = -* Added a one-click setup option to the Onboarding Wizard to apply recommended settings automatically. -* Added Migration Wizard to help migrate from Google reCAPTCHA and Cloudflare Turnstile to hCaptcha. -* Added a dedicated Anti-Spam settings tab, moving bot protection, access control, and login security settings out of the General page. -* Added disposable email blocking with an auto-updating blocklist of 4,000+ temporary email domains. -* Added sorting country names on the Anti-Spam tab. -* Added Include Site and Secret Keys checkbox to the options import dialog. -* Added support for Forminator multi-step forms when hCaptcha is not explicitly inserted into the form. -* Added hcap_delay_api_selector filter to delay API script loading based on selector. -* Added compatibility with WordPress 7.0. -* Fixed a fatal error when processing Jetpack Forms with multiple choices. -* Fixed settings export and import on multisite. -* Fixed settings import with changes not on the General tab. -* Fixed MaxMind DB update schedule not being stopped when the license key is removed. -* Fixed detection of the email address in the Contact Form 7 form. -* Fixed a fatal error in the wp_login_form () function and LoginOut block. - -= 4.24.0 = -* Added country allowlist and denylist to control where hCaptcha protections apply. -* Added compatibility with Payment Plugins for Stripe WooCommerce. -* Added support for the Add Payment Method form in the WooCommerce My Account page. -* Improved Custom Theme editor with live preview on color and JSON changes. -* Fixed a fatal error occurred with WP-CLI in a rare case. -* Fixed a PHP notice occurred on the Contact Form 7 edit form page. -* Fixed inability to log in and register from the Woocommerce My Account page. -* Fixed inability to send a form with invisible or forced hCaptcha. -* Fixed mobile layout of the Anti-Spam and Notifications blocks on the General admin page. -* Fixed processing options before saving on multisite. -* Fixed empty charts on Forms and Events admin pages. -* Fixed Custom and Enterprise section inputs with free license. - -= 4.23.0 = -* Added 'hcap_trusted_address_headers' filter to customize the list of IP-related headers used for client IP detection. -* Added admin tools for exporting and importing plugin settings. -* Added WP-CLI support for exporting and importing plugin settings. -* Added support for exporting and importing plugin settings via Abilities API. -* Added temporary submit button locking when using Invisible hCaptcha to prevent multiple submissions. -* Fixed a fatal error related to the missing events database table. -* Fixed fatal errors on Forms and Events pages when the Events table was empty. -* Fixed admin pages layout with the latest WordPress changes. -* Fixed challenging hCaptcha with the express checkout methods on WooCommerce Checkout page. - -= 4.22.0 = -* The minimum required PHP version is now 7.4. -* The minimum required WordPress version is now 6.0. -* Added support for 1Password on the General page. -* Fixed an issue where third-party plugins calling WordPress core functions incorrectly could break the What's New admin scripts and styles. -* Fixed LearnDash Login form. - -= 4.21.1 = -* Fixed hCaptcha not loading on Contact Form 7. - -= 4.21.0 = -* Added AI-ready security actions via the WordPress Abilities API, enabling automated threat inspection and response. -* Added compatibility with the latest version of the Ninja Forms plugin. -* Fixed Fluent Forms integrations after the latest Fluent Forms update. -* Fixed the inability to send Fluent Forms Conversational Form. -* Fixed the racing condition which sometimes led to double rendering of the hCaptcha widget on any forms. -* Fixed double rendering of the hCaptcha widget on the Elementor Form. -* Fixed an error activating a free plugin when its premium version is not available. -* Fixed highlighting of the suggested plugin when it is already activated. -* Fixed an attempt for installation of an already installed plugin. -* Fixed installing plugins declared as WordPress dependencies. -* Fixed Jetpack test form appearance on the Playground. - -= 4.20.0 = -* Added Divi 5 support. -* Added onboarding wizard. -* Added a dynamic Playground menu update after activating and deactivating of plugins and themes. -* Added support for the Essential Addons for Elementor Pro plugin. -* Added the ability to show What's New info for any version with the 'whats_new' GET parameter. -* Fixed "[hCaptcha] should not render before js api is fully loaded" warning on the General page. -* Fixed race condition when loading JavaScript on Playground. -* Fixed console errors with Divi Contact Form. -* Fixed the placeholder layout following hCaptcha layout changes. -* Fixed the empty hCaptcha error on the WooCommerce Checkout page when honeypot is off. -* Fixed multiple hCaptcha widgets on the Mailchimp form preview. -* Fixed hCaptcha What's New layout. -* Fixed error messaging in Jetpack forms. - -= 4.19.0 = -* Added Elementor Safe Mode information to the System Info admin page. -* Added support for WP Multisite Signup form. -* Added support for a Theme My Login Multisite Signup form. -* Added test mode by default on the first installation. -* Improved a page speed load via passive event listeners. -* Improved the notification message on the placeholder when hCaptcha is not loaded. -* Fixed the delay showing hCaptcha when a WP Login form is protected. -* Fixed the delay showing hCaptcha when a Colorlib Customizer Login form is protected. -* Fixed the delay showing hCaptcha when an Easy Digital Download Login form is protected. -* Fixed Ajax requests on admin pages with WP Playground. -* Fixed the JS error on Forms and Events admin pages with empty data. -* Fixed a fatal error when only Elementor is installed without Pro. -* Fixed 'Bad hCaptcha nonce' error with a direct `/wp-login.php?action=postpass` request. -* Fixed plugin activation on the Integrations page, executing activation hooks. -* Fixed detection of the editor pages when hCaptcha is disabled for logged-in users. -* Fixed Elementor Pro Form with default hCaptcha settings. -* Fixed the zero size of the placeholder with default settings. - -= 4.18.0 = -* Added honeypot and minimum submit time support for Blocksy, Brevo, CoBlocks, Contact Form 7 Live Form, Download Manager, Essential Blocks, Fluent Forms, Formidable Forms, Forminator, GiveWP Form, Gravity Forms, Kadence, MailPoet, Otter, Password Protected, Ultimate Addons for Elementor, and Wordfence. -* Added the ability to specify Protected URLs on the General page with schema and domain, allowing to protect specific pages on multisite. -* Added plugin Live Preview on playground.wordpress.net. -* Fixed a fatal error during the installation of an integration plugin in some cases. -* Fixed Fluent Forms Login behavior when the login limit was exceeded. -* Fixed integration upon Kadence Blocks plugin update. -* Fixed integration upon Otter Blocks plugin update. -* Fixed integration Paid Membership Pro plugin update. -* Fixed integration upon ACF Extended plugin update. -* Fixed duplicated hCaptcha renders with Dark mode enabled. -* Fixed the Sendinblue error on multiple submitting. - -= 4.17.0 = -* Added a hidden honeypot field and minimum submit time for bot detection before processing hCaptcha. Currently supported for WordPress Core, Avada theme, Contact Form 7, Divi theme, Divi Builder, Essential Addons for Elementor, Extra theme, Elementor, Jetpack, Mailchimp, Ninja Forms, Spectra, WooCommerce, WPForms, Protect Content feature. -* Added a notification message on the placeholder when hCaptcha failed to load. -* Added hCaptcha error messages to the Divi optin email form. -* Added hCaptcha error messages to the JetPack form. -* Fix conflict with reCaptcha in Divi Contact form. -* Fixed running migrations on a fresh installation. -* Fixed updating the migration option on each admin request. -* Fixed a fatal error on saving the Integration settings with active plugins having all switches off. -* Fixed an issue with several Divi optin email forms on the page. -* Fixed activation of Blocksy plugins with any theme. -* Fixed refreshing hCaptcha upon failed submission in Jetpack. -* Fixed Ninja Forms uncaught type error in JS appeared with some field types. -* Fixed Ninja Forms integration when form duplicates are on the same page. - -= 4.16.0 = -* Added integration with Germanized for WooCommerce. -* Added integration with Icegram Express, including popup and widget forms. -* Added integration with Fluent Forms Multi-Step forms. -* Added integration with Customer Reviews for WooCommerce. -* Added integration with the Blocksy Companion Newsletter Subscribe, Waitlist, and Product Review forms. -* Fixed showing hCaptcha in Jetpack Form admin. -* Fixed PHP warning on installing the integration plugin. -* Fixed PHP warning when processing Advanced Kadence Form. -* Fixed activation of Pro and Lite integrations when both are needed. -* Fixed Fluent Conversational Form with embedded hCaptcha. -* Fixed edge case bugs with saving settings on a single site, multi-site. -* Fixed network-wide setting functionality with per-site and network plugin activation. -* Fixed a fatal error when attempting to activate Blocksy Companion Pro and Free plugins together. -* Fixed IP CIDR range detection. -* Improved redirect when turning off the network-wide setting. - -= 4.15.0 = -* Added hCaptcha error messages to CoBlocks. -* Added hCaptcha error messages to Spectra. -* Added compatibility with Contact Form 7 v6.1. -* Fixed PHP 8.1 deprecated error. -* Fixed the layout of the General page on mobile. -* Fixed the layout of Notifications on the General page. -* Fixed admin page colors respecting Admin Color Scheme. - -= 4.14.0 = -* Added Ultimate Addons for Elementor integration. -* Added compatibility with the ActivityPub plugin. -* Added denylisted IP addresses on the General page. -* Added validation of IP addresses on the General page. -* Fixed the conflict with Contact Form 7 plugin reCaptcha integration. -* Fixed fatal error with the wpDiscuz 7.6.30. - -= 4.13.0 = -* Added site content protection. -* Added the "Remove Data on Uninstall" option to improve user privacy. -* Added the "What's New" popup on admin pages. -* Added Events Manager integration. -* Added Password Protected integration. -* Added compatibility with Formidable Forms Pro. -* Added support for Avada multistep forms. -* Improved support of the device color scheme. -* Fixed enqueuing hCaptcha scripts on every page when Fluent Forms integration is on. -* Fixed warning in with auto-verify forms, including Brevo. -* Fixed enqueuing script with Fluent Conversational Form. -* Fixed showing hCaptcha with the latest Fluent Forms version. -* Fixed Conversational forms support with the latest Fluent Forms version. -* Fixed the race condition when highlighting admin elements. -* Tested with WordPress 6.7. -* Tested with WooCommerce 9.8. - -= 4.12.0 = -* Added 'hcap_print_hcaptcha_scripts' filter. -* Added the ability to filter printing of dsn-prefetch link and inline styles. -* Added auto-forcing and prevent delaying of hCaptcha on login forms for 1Password compatibility. -* Added auto-forcing and prevent delaying of hCaptcha on login forms for LastPass compatibility. -* Added Privacy Policy to WordPress admin Privacy > Policy Guide page. -* Improved API script delay behavior. Now, scripts are loaded after a delay interval or any user interaction, whichever happens first. -* Improved scrolling behavior to highlight elements in admin. -* Fixed the broken 'submit' button with ACF, Gravity Forms, and input to button snippet. -* Fixed printing hCaptcha scripts on the Essential Addons preview page. -* Fixed hCaptcha layout on wpDiscuz forms. -* Fixed the race condition with Pro invisible hCaptcha. -* Fixed the scroll on a page loading with a Kadence form. -* Fixed scroll on a page load with a Kadence Advanced form. -* Fixed scrolling and focusing after submitting with CF7 form. -* Fixed scrolling and focusing after submitting with a Forminator form. -* Fixed scrolling and focusing after submitting with a Quform form. -* Fixed scrolling and focusing after submitting with an Elementor form. -* Fixed scrolling and focusing after submitting with Autoverify in Ajax. -* Fixed scrolling and focusing before checking the Site Config on the General page. -* Fixed the fatal error on claiming action during migration to 4.11.0. -* Fixed fatal error when migrating to 4.0.0 via cron. -* Fixed the WordPress database error on migrating to 4.11.0 in a rare case. - -= 4.11.0 = -* Added Really Simple CAPTCHA plugin integration. -* Added compatibility with the UsersWP plugin v1.2.28. -* Added compatibility with the Perfmatters plugin. -* Added support for the Fluent Login form. -* Added confirmation messages upon deletion of events on the Forms and Events pages. -* Added asynchronous migrations for large databases. -* Added hCaptcha error messages to the Contact Form 7 when JavaScript is disabled. -* Optimized Forms page performance for large databases with millions of entries. -* Fixed processing wpDiscuz comment form with wpDiscuz custom ajax. -* Fixed adding hCaptcha internal fields to Avada from submission. -* Fixed ASC ordering by date on the Events page. -* Fixed selection of a time interval on the Events page when site local time was not GMT. -* Fixed losing options during plugin update in rare cases. -* Fixed the live hCaptcha tag on the Contact Form 7 edit page after insertion but before saving the form. -* Fixed shortcode processing in the Contact Form 7 form when Auto-Add was off. -* Fixed the error on theme installation. -* Tested with WooCommerce 9.7. - -= 4.10.0 = -* Added support for the wp_login_form () function and LoginOut block. -* Added support for hCaptcha in HTML Gravity Forms fields. -* Added support for custom nonce action and name in the [hcaptcha] shortcode. -* Added compatibility with Cookies and Content Security Policy plugin. -* Added auto-verification of arbitrary forms in ajax. -* Added deletion of events on the Forms page. -* Added deletion of events on the Events page. -* Improved error messaging for hCaptcha verification. -* Fixed IP detection in the WordPress core via filter. Now syncs with hCaptcha event information when an IP collection is activated. -* Fixed a fatal error with the WPForms plugin in rare cases. -* Fixed the error message at the first entry to the login page when Hide Login Errors in on. -* Fixed scrolling to the message on the General page. -* Fixed a fatal error during integration installation in some cases. -* Fixed the Integrations page when the active plugin was deleted. -* Fixed the error when hCaptcha is disabled for standard login but enabled for LearnPress login. -* Fixed the error when hCaptcha is disabled for standard login but enabled for Tutor login. -* Fixed the layout for Forms and Events pages on small screens. - -= 4.9.0 = -* Added LearnPress integration. -* Added Tutor LMS integration. -* Added compatibility with Ninja Forms v3.8.22. -* Added the ability to install plugins and themes from the Integrations page. -* Added the ability to hide the login errors. -* Added an anonymous collection of IP and User Agent data in locally stored analytics to simplify GDPR compliance. -* Added extended info about the IP address on the Events page on hover. -* Added selecting any page on Forms and Events. -* Optimized Events page performance for large databases with millions of entries. -* Fixed the layout of a modern Jetpack form in outlined and animated styles. -* Fixed a fatal error as a consequence of a bug in the TutorLMS. -* Fixed the help text box layout on the General page. -* Fixed the dismiss and reset Notifications actions. -* Fixed duplication of entries in the Events table. - -= 4.8.0 = -* Added instant updating of the Contact Form 7 live form. -* Added hCaptcha display on the Mailchimp form preview. -* Added Maintenance Login Form integration. -* Added Extra theme integration. -* Added Divi Builder plugin integration. -* Added theme argument to the [hcaptcha] shortcode. -* Added a 'theme' badge to themes on the Integrations page. -* Updated hCaptcha API error codes. -* Fixed processing of a Divi form with diacritical marks. -* Fixed deactivating of all themes by Ctrl+Click on the Integrations page. -* Fixed the theme name display upon activation. -* Fixed the display of the hCaptcha shortcode with individual parameters. -* Fixed the usage of theme in shortcode and form args. -* Fixed instant update upon theme selection on the General admin page. -* Fixed custom themes on the frontend. -* Fixed custom themes on the General page. -* Fixed switching from custom themes to a standard and back on the General page. -* Fixed switching from live to test mode and back on the General page. -* Tested with PHP 8.4.1. - -= 4.7.1 = -* Fixed _load_textdomain_just_in_time notice with WordPress 6.7. -* Some translations were empty with WordPress 6.5+. - -= 4.7.0 = -* Added compatibility with WordPress Recovery Mode. -* Added compatibility with Contact Form 7 v6.0. -* Added compatibility with the Akismet tag in Contact Form 7. -* Added compatibility with Elementor Element Caching. -* Added activation and deactivation of the plugin network wide if hCaptcha is set network wide. -* Added the ability to use shortcode in the Jetpack Classic form. -* Added the ability to use shortcode in the Mailchimp for WP form. -* Fixed the race condition when loading hCaptcha API. -* Fixed sending a Ninja form with solved hCaptcha. -* Fixed non-active hCaptcha when editing a page containing a Forminator form. -* Fixed launching a notification script on every admin page. -* Fixed missing hCaptcha in Formidable forms. -* Fixed non-blocking of reCaptcha scripts with Kadence Forms. -* Fixed showing hCaptcha in Elementor admin in some cases. -* Fixed the inability to sort by Source column on Forms and Events admin pages. -* Fixed the inability to deactivate the Avada theme right after activation. -* Fixed the inability to deactivate the Divi theme right after activation. -* Fixed the error on plugin activation when the plugin makes redirect on activation. -* Fixed the open_basedir restriction warning in Query Monitor. -* Tested with WordPress 6.7. -* Tested with WooCommerce 9.3. - -= 4.6.0 = -* Added support for Simple Membership Login, Register, and Lost Password forms. -* Added an option to show Live Form in CF7 admin. -* Added hCaptcha tab on the Gravity Forms settings page. -* Added uninstallation code to delete plugin data. -* Improved compatibility with hCaptcha API. -* Fixed the appearance of hCaptcha in the Ninja Form admin editor after form saving only. -* Fixed no rendering of hCaptcha in the Gravity Forms admin editor after adding the hCaptcha field. -* Fixed no rendering of hCaptcha in the Essential Addons admin editor. -* Fixed switching between Security Settings on the Fluent Forms Global Settings page. -* Fixed the layout for settings pages with RTL languages. -* Fixed the layout for Contact Form 7 with RTL languages. - -= 4.5.0 = -* Added support for Jetpack forms in block theme templates. -* Added support for bbPress Login, Register, and Lost Password forms. -* Added the second argument $atts to the 'hcap_hcaptcha_content' filter. -* Added support for MailPoet forms at any placement. -* Added the ability to have multiple MailPoet forms on the same page. -* Improved UX of the Integrations page. -* Fixed error messaging when there are several Jetpack forms on the same page. -* Fixed unconditional forcing hCaptcha in Jetpack forms. -* Fixed the appearance of the Beaver Builder editor with the "Turn Off When Logged In" setting. -* Fixed the appearance of the Contact Form 7 editor with the "Turn Off When Logged In" setting. -* Fixed the appearance of the Essential Addons editor with the "Turn Off When Logged In" setting. -* Fixed the appearance of the Gravity Forms editor with the "Turn Off When Logged In" setting. -* Fixed the appearance of the Fluent Forms editor with the "Turn Off When Logged In" setting. -* Fixed the appearance of the Forminator editor with the "Turn Off When Logged In" setting. -* Fixed the appearance of Formidable Forms with the "Turn Off When Logged In" setting. -* Fixed the appearance of the Ninja Forms editor with the "Turn Off When Logged In" setting. -* Fixed the appearance of the WPForms editor with the "Turn Off When Logged In" setting. -* Fixed a fatal error on the Gravity Forms Entries page. -* Fixed Elementor preview. -* Fixed Ninja Forms preview. -* Fixed hCaptcha nonce error on MailPoet admin pages. -* Fixed the frontend error when recaptcha was activated in wpDiscuz. - -= 4.4.0 = -* Added compatibility with Contact Form 7 Stripe integration. -* Added compatibility with the WPS Hide Login plugin. -* Added compatibility with the LoginPress plugin. -* Improved compatibility with the Wordfence Login Security plugin. -* Updated MailPoet integration. -* Fixed the conflict with the Ninja Forms Upload field. -* Fixed Ninja Forms Ajax processing. -* Fixed the error in cron with Matomo Analytics. -* Fixed the error with the Elementor Checkout Element. -* Fixed ignorance of Pro params in the Elementor admin editor. -* Fixed the inability to activate the Elementor Pro plugin when Elementor plugin is activated. -* Fixed sending replies to wpDiscuz comments. -* Fixed replies in the WP Dashboard with wpDiscuz active. -* Fixed sending several wpDiscuz comments without a page reload. - -= 4.3.1 = -* Added a live form in the Contact Form 7 admin form view. -* Fixed warnings and deprecation messages in admin when Contact Form 7 is active. -* Fixed the tag generator with the live form for Contact Form 7. -* Fixed a fatal error with Gravity Forms. - -= 4.3.0 = -* NOTE: the plugin has been renamed from "hCaptcha for WordPress" to "hCaptcha for WP" -* Dropped support for PHP 7.0 and 7.1. The minimum required PHP version is now 7.2. -* Added a live form in the Contact Form 7 admin editor. -* Added support for Contact Form 7 embedded forms. -* Added support for the WooCommerce Checkout block. -* Added support for GiveWP block forms created via Form Builder. -* Added check if a plugin or theme is installed before activation. -* Added activation of dependent plugins with a theme. -* Fixed missing sitekey error processing on the General page. -* Fixed the naming of the first submenu item. -* Fixed the storing of check config events to the database. -* Fixed notifications links in menu pages mode. -* Fixed Firefox issue with not showing hCaptcha when the API script was delayed until user interaction. -* Fixed the error on activation/deactivation of a theme. -* Fixed error on activating Brizy plugin. -* Fixed issue with updated Brizy plugin. -* Fixed the issue with the updated Divi EmailOptin module. -* Tested with WordPress 6.6. -* Tested with WooCommerce 9.0. - -= 4.2.1 = -* Fixed the message layout on the General and Integrations pages. -* Fixed processing of the WooCommerce Register form. - -= 4.2.0 = -* The minimum required WordPress version is now 5.3. -* Added support for Multisite Network Admin synced with network-wide plugin options. -* Added selection by date range on Forms and Events pages. -* Added automatic activation of dependent plugins on the Integrations page. -* Added scrolling on the Integrations page during the search. -* Fixed color flickering of hCaptcha placeholder with custom themes. -* Fixed the JS error on the Lost Password page. -* Fixed the missing site key notification on the General page. -* Fixed a fatal error on some sites during migration to 4.0.0. - -= 4.1.2 = -* Added an option to have the hCaptcha admin menu under Settings. -* Fixed the General admin page on the mobile. -* Fixed Forms and Events admin pages on the mobile. - -= 4.1.1 = -* Added updating of the Custom Themes properties on the General page upon manual editing of the Config Params JSON. -* Fixed a possible fatal error with third-party plugins using a Jetpack library. - -= 4.1.0 = -* Added Essential Blocks integration. -* Added hideable columns to Forms and Events tables. -* Admin menu moved to the top level with subpages. -* Added a filter to change the admin menu appearance. -* Add a modern dialog to the System Info admin page. -* Add a modern dialog to the Gravity Forms edit page. -* Add a modern dialog to the Ninja Forms edit page. -* Tested with WooCommerce 8.8. - -= 4.0.1 = -* Added pagination to the Forms and Events pages. -* Fixed the PHP notice on the Forms page. - -= 4.0.0 = -* This major release adds a new Statistics feature and many admin improvements. -* Added hCaptcha events statistics and Forms admin page. -* Added Events admin page for Pro users. -* Added Custom Theme Editor for Pro users. -* Added a Force option to show hCaptcha challenge before submit. -* Added integration with Essential Addons for Elementor — the Login/Register form. -* Added filter `hcap_form_args` to allow modifying form arguments. -* Reworked Otter integration to follow Force and all other hCaptcha settings. -* Fixed the issue with Divi Contact Form Helper plugin and File Upload field. -* Fixed showing an internal console message on the General page when reCaptcha compatibility was disabled. -* Fixed the racing condition with hCaptcha script loading. -* Fixed checking nonce in CF7 for not logged-in users. -* Tested with WooCommerce 8.7. - [See changelog for all versions](https://plugins.svn.wordpress.org/hcaptcha-for-forms-and-more/trunk/changelog.txt). diff --git a/src/php/ACFE/Form.php b/src/php/ACFE/Form.php index a4e8f06f1..068968d7b 100644 --- a/src/php/ACFE/Form.php +++ b/src/php/ACFE/Form.php @@ -199,7 +199,16 @@ public function verify( $valid, string $value, array $field, string $input ) { $form_id = $transient[0] ?? false; $result = $transient[1] ?? false; - return $form_id === $this->form_id ? $result : false; + if ( $form_id === $this->form_id ) { + return $result; + } + + // Some ACFE forms submit directly without the AJAX validation step. + // In that case, verify the current POST once instead of requiring a transient. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $post_data = wp_unslash( $_POST ); + + return API::verify( $this->get_entry( $post_data ) ) ?: true; } /** @@ -220,6 +229,19 @@ private function get_entry( array $post_data ): array { 'form_date_gmt' => $form_post->post_modified_gmt ?? null, 'post_data' => $post_data, 'data' => $this->get_data( $acf_data ), + 'expected_id' => $this->get_expected_id(), + ]; + } + + /** + * Get expected hCaptcha widget id. + * + * @return array + */ + private function get_expected_id(): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => $this->form_id, ]; } @@ -318,12 +340,17 @@ public function enqueue_scripts(): void { return; } - $min = hcap_min_suffix(); + $min = hcap_min_suffix(); + $deps = [ 'jquery', 'hcaptcha' ]; + + if ( wp_script_is( 'acf-extended-input', 'registered' ) ) { + $deps[] = 'acf-extended-input'; + } wp_enqueue_script( self::HANDLE, HCAPTCHA_URL . "/assets/js/hcaptcha-acfe$min.js", - [ 'jquery', 'hcaptcha' ], + $deps, HCAPTCHA_VERSION, true ); diff --git a/src/php/Abilities/Abilities.php b/src/php/Abilities/Abilities.php index d6685e25c..61f50b55e 100644 --- a/src/php/Abilities/Abilities.php +++ b/src/php/Abilities/Abilities.php @@ -182,14 +182,13 @@ public function register_abilities(): void { 'signals' => [ 'type' => 'object', 'properties' => [ - 'attack_likelihood' => [ 'type' => 'string' ], - 'confidence' => [ 'type' => 'string' ], - 'top_vectors' => [ + 'confidence' => [ 'type' => 'string' ], + 'top_vectors' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], ], ], - 'required' => [ 'attack_likelihood', 'confidence', 'top_vectors' ], + 'required' => [ 'confidence', 'top_vectors' ], 'additionalProperties' => false, ], 'breakdown' => [ @@ -770,9 +769,8 @@ static function ( array $a, array $b ): int { 'fail_rate' => number_format( $fail_rate, 2 ), ], 'signals' => [ - 'attack_likelihood' => $this->calculate_attack_likelihood( $total, $failed_count, $fail_rate ), - 'confidence' => $this->calculate_confidence( $total ), - 'top_vectors' => $top_vectors, + 'confidence' => $this->calculate_confidence( $total ), + 'top_vectors' => $top_vectors, ], 'breakdown' => [ 'errors' => $error_counts, @@ -797,9 +795,8 @@ private function get_empty_threat_snapshot( bool $formatted = false ): array { 'fail_rate' => $formatted ? '0.00' : 0.0, ], 'signals' => [ - 'attack_likelihood' => 'low', - 'confidence' => 'low', - 'top_vectors' => [], + 'confidence' => 'low', + 'top_vectors' => [], ], 'breakdown' => [ 'errors' => [], @@ -1057,31 +1054,6 @@ static function ( string $a, string $b ) use ( $map ): int { return $map; } - /** - * Calculate attack likelihood. - * - * @param int $total Total events. - * @param int $failed Failed events. - * @param float $fail_rate Fail rate. - * - * @return string - */ - private function calculate_attack_likelihood( int $total, int $failed, float $fail_rate ): string { - if ( $total < 20 ) { - return 'low'; - } - - if ( $fail_rate >= 0.50 && $failed >= 10 ) { - return 'high'; - } - - if ( $fail_rate >= 0.20 && $failed >= 5 ) { - return 'medium'; - } - - return 'low'; - } - /** * Calculate confidence. * diff --git a/src/php/Admin/Events/Events.php b/src/php/Admin/Events/Events.php index 4b3c29825..9dc4d5573 100644 --- a/src/php/Admin/Events/Events.php +++ b/src/php/Admin/Events/Events.php @@ -33,6 +33,11 @@ class Events { */ public const SERVED_LIMIT = 1000; + /** + * Maximum indexed source length. + */ + public const SOURCE_INDEX_LENGTH = 191; + /** * Active event status. */ @@ -416,7 +421,8 @@ public static function create_table( bool $force = false ): void { self::unmark_table_created(); } - $table_name = self::TABLE_NAME; + $table_name = self::TABLE_NAME; + $source_index_length = self::SOURCE_INDEX_LENGTH; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; @@ -434,14 +440,14 @@ public static function create_table( bool $force = false ): void { status VARCHAR(20) NOT NULL DEFAULT 'active', trashed_at_gmt DATETIME NULL, PRIMARY KEY (id), - KEY source (source), + KEY source (source($source_index_length)), KEY form_id (form_id), - KEY hcaptcha_id (source, form_id), + KEY hcaptcha_id (source($source_index_length), form_id), KEY ip (ip), KEY uuid (uuid), KEY date_gmt (date_gmt), KEY status_date_gmt (status, date_gmt), - KEY status_source_form (status, source, form_id) + KEY status_source_form (status, source($source_index_length), form_id) ) $charset_collate"; dbDelta( $sql ); diff --git a/src/php/Admin/Privacy.php b/src/php/Admin/Privacy.php index 364da0cb3..a0d992d51 100644 --- a/src/php/Admin/Privacy.php +++ b/src/php/Admin/Privacy.php @@ -53,7 +53,9 @@ private function init_hooks(): void { */ public function add_privacy_message(): void { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { + // @codeCoverageIgnoreStart return; + // @codeCoverageIgnoreEnd } $content = $this->get_privacy_message(); diff --git a/src/php/Admin/SupportModal.php b/src/php/Admin/SupportModal.php new file mode 100644 index 000000000..34e9e54a8 --- /dev/null +++ b/src/php/Admin/SupportModal.php @@ -0,0 +1,477 @@ +init(); + } + + /** + * Init class hooks. + * + * @return void + */ + public function init(): void { + add_action( 'kagg_settings_header', [ $this, 'render_actions_start' ], 0 ); + add_action( 'kagg_settings_header', [ $this, 'render_button' ], 20 ); + add_action( 'kagg_settings_header', [ $this, 'render_actions_end' ], PHP_INT_MAX ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); + add_action( 'admin_footer', [ $this, 'render_modal' ] ); + } + + /** + * Enqueue assets. + * + * @return void + */ + public function enqueue_assets(): void { + if ( ! $this->is_hcaptcha_admin_page() ) { + return; + } + + $min = hcap_min_suffix(); + + wp_enqueue_style( + self::HANDLE, + constant( 'HCAPTCHA_URL' ) . "/assets/css/support-modal$min.css", + [], + constant( 'HCAPTCHA_VERSION' ) + ); + + wp_enqueue_script( + self::HANDLE, + constant( 'HCAPTCHA_URL' ) . "/assets/js/support-modal$min.js", + [], + constant( 'HCAPTCHA_VERSION' ), + true + ); + + wp_localize_script( + self::HANDLE, + self::OBJECT, + [ + 'githubIssueUrl' => self::GITHUB_ISSUE_URL, + 'wordpressSupportUrl' => self::WORDPRESS_SUPPORT_URL, + 'systemInfo' => $this->get_system_info(), + 'strings' => $this->get_script_strings(), + ] + ); + } + + + /** + * Render header actions start. + * + * @return void + */ + public function render_actions_start(): void { + if ( ! $this->is_hcaptcha_admin_page() ) { + return; + } + + ?> +
+ is_hcaptcha_admin_page() ) { + return; + } + + ?> + + is_hcaptcha_admin_page() ) { + return; + } + + ?> +
+ is_hcaptcha_admin_page() ) { + return; + } + + ?> + + id ); + $current_suffix = preg_replace( '/-network$/', '', (string) $current_suffix ); + + return 0 === strpos( (string) $current_suffix, 'hcaptcha' ); + } + + /** + * Render request type radios. + * + * @return void + */ + private function render_request_type_radios(): void { + $types = [ + 'bug' => __( 'Report a bug', 'hcaptcha-for-forms-and-more' ), + 'feature' => __( 'Request a feature', 'hcaptcha-for-forms-and-more' ), + 'support' => __( 'Ask a setup question', 'hcaptcha-for-forms-and-more' ), + ]; + + foreach ( $types as $value => $label ) { + $id = 'hcaptcha-support-type-' . $value; + + ?> + + __( 'Login', 'hcaptcha-for-forms-and-more' ), + 'Registration' => __( 'Registration', 'hcaptcha-for-forms-and-more' ), + 'Comments' => __( 'Comments', 'hcaptcha-for-forms-and-more' ), + 'WooCommerce' => __( 'WooCommerce', 'hcaptcha-for-forms-and-more' ), + 'WooCommerce PayPal Payments' => __( 'WooCommerce PayPal Payments', 'hcaptcha-for-forms-and-more' ), + 'WPForms' => __( 'WPForms', 'hcaptcha-for-forms-and-more' ), + 'Gravity Forms' => __( 'Gravity Forms', 'hcaptcha-for-forms-and-more' ), + 'Elementor' => __( 'Elementor', 'hcaptcha-for-forms-and-more' ), + 'Contact Form 7' => __( 'Contact Form 7', 'hcaptcha-for-forms-and-more' ), + 'Forminator' => __( 'Forminator', 'hcaptcha-for-forms-and-more' ), + 'Fluent Forms' => __( 'Fluent Forms', 'hcaptcha-for-forms-and-more' ), + 'Formidable Forms' => __( 'Formidable Forms', 'hcaptcha-for-forms-and-more' ), + 'Kadence Forms' => __( 'Kadence Forms', 'hcaptcha-for-forms-and-more' ), + 'Other' => __( 'Other', 'hcaptcha-for-forms-and-more' ), + ]; + + foreach ( $options as $value => $label ) { + ?> + + +

+ + +

+ settings(); + + if ( ! $settings ) { + return ''; + } + + $system_info = $settings->get_tab( SystemInfo::class ); + + if ( ! $system_info instanceof SystemInfo ) { + return ''; + } + + return $system_info->get_system_info(); + } + + /** + * Get script strings. + * + * @return array + */ + private function get_script_strings(): array { + return [ + 'copySuccess' => __( 'Report copied to clipboard.', 'hcaptcha-for-forms-and-more' ), + 'copyError' => __( 'Cannot copy the report automatically. Please copy it from the report field.', 'hcaptcha-for-forms-and-more' ), + 'summaryRequired' => __( 'Please enter a summary before continuing.', 'hcaptcha-for-forms-and-more' ), + 'openFailed' => __( 'Your browser blocked the new tab. Please allow popups and try again.', 'hcaptcha-for-forms-and-more' ), + 'emptyValue' => __( 'Not provided', 'hcaptcha-for-forms-and-more' ), + 'report' => [ + 'summary' => __( 'Summary', 'hcaptcha-for-forms-and-more' ), + 'affectedArea' => __( 'Affected area', 'hcaptcha-for-forms-and-more' ), + 'steps' => __( 'Steps to reproduce', 'hcaptcha-for-forms-and-more' ), + 'expected' => __( 'Expected behavior', 'hcaptcha-for-forms-and-more' ), + 'actual' => __( 'Actual behavior', 'hcaptcha-for-forms-and-more' ), + 'additional' => __( 'Additional details', 'hcaptcha-for-forms-and-more' ), + 'diagnostics' => __( 'Diagnostic information', 'hcaptcha-for-forms-and-more' ), + 'feature' => __( 'Feature request', 'hcaptcha-for-forms-and-more' ), + 'problem' => __( 'Problem / use case', 'hcaptcha-for-forms-and-more' ), + 'solution' => __( 'Proposed solution', 'hcaptcha-for-forms-and-more' ), + 'alternatives' => __( 'Alternative considered / notes', 'hcaptcha-for-forms-and-more' ), + 'question' => __( 'Question', 'hcaptcha-for-forms-and-more' ), + 'configure' => __( 'What I am trying to configure', 'hcaptcha-for-forms-and-more' ), + 'tried' => __( 'What I already tried', 'hcaptcha-for-forms-and-more' ), + ], + ]; + } +} diff --git a/src/php/Admin/WhatsNew.php b/src/php/Admin/WhatsNew.php index e033ce953..75c11b22b 100644 --- a/src/php/Admin/WhatsNew.php +++ b/src/php/Admin/WhatsNew.php @@ -33,14 +33,14 @@ class WhatsNew extends NotificationsBase { private const MARK_SHOWN_ACTION = 'hcaptcha-mark-shown'; /** - * Settings key for a last shown What's New version. + * Render popup ajax action. */ - private const WHATS_NEW_KEY = 'whats_new_last_shown_version'; + private const RENDER_POPUP_ACTION = 'hcaptcha-render-whats-new-popup'; /** - * Query parameter for forcing What's New popup. + * Settings key for a last shown What's New version. */ - private const WHATS_NEW_PARAM = 'whats_new'; + private const WHATS_NEW_KEY = 'whats_new_last_shown_version'; /** * Method prefix. @@ -89,6 +89,7 @@ private function init_hooks(): void { add_action( 'admin_print_footer_scripts', [ $this, 'enqueue_assets' ], 9 ); add_action( 'admin_footer', [ $this, 'maybe_show_popup' ] ); add_action( 'wp_ajax_' . self::MARK_SHOWN_ACTION, [ $this, 'mark_shown' ] ); + add_action( 'wp_ajax_' . self::RENDER_POPUP_ACTION, [ $this, 'render_popup_ajax' ] ); add_filter( 'update_footer', [ $this, 'update_footer' ], 1010 ); } @@ -130,10 +131,11 @@ public function enqueue_assets(): void { self::HANDLE, self::OBJECT, [ - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), - 'markShownAction' => self::MARK_SHOWN_ACTION, - 'markShownNonce' => wp_create_nonce( self::MARK_SHOWN_ACTION ), - 'whatsNewParam' => self::WHATS_NEW_PARAM, + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'markShownAction' => self::MARK_SHOWN_ACTION, + 'markShownNonce' => wp_create_nonce( self::MARK_SHOWN_ACTION ), + 'renderPopupAction' => self::RENDER_POPUP_ACTION, + 'renderPopupNonce' => wp_create_nonce( self::RENDER_POPUP_ACTION ), ] ); } @@ -148,31 +150,12 @@ public function maybe_show_popup(): void { return; } - $prefix = self::PREFIX; - $forced = Request::filter_input( INPUT_GET, self::WHATS_NEW_PARAM ); - $forced = $this->normalize_version( $forced ); - $current = $forced ?: explode( '-', constant( 'HCAPTCHA_VERSION' ) )[0]; + $current = explode( '-', constant( 'HCAPTCHA_VERSION' ) )[0]; $settings = hcaptcha()->settings(); $whats_new_key = $settings ? $settings->get( self::WHATS_NEW_KEY ) : ''; - $shown = $forced ? '' : $whats_new_key; - $methods = array_filter( - get_class_methods( $this ), - static function ( $method ) use ( $prefix ) { - return 0 === strpos( $method, $prefix ); - } - ); - $versions = array_map( - function ( $method ) { - return $this->method_to_version( $method ); - }, - $methods - ); - - usort( $versions, 'version_compare' ); - - // Sort versions in descending order. - $versions = array_reverse( $versions ); - $method = ''; + $shown = $whats_new_key; + $versions = $this->get_versions(); + $method = ''; foreach ( $versions as $version ) { // Find the first news version that is less or equal to the current version. @@ -210,6 +193,39 @@ public function mark_shown(): void { wp_send_json_success(); } + /** + * Ajax action to render a popup for a selected version. + * + * @return void + */ + public function render_popup_ajax(): void { + // Run a security check. + if ( ! check_ajax_referer( self::RENDER_POPUP_ACTION, 'nonce', false ) ) { + wp_send_json_error( esc_html__( 'Your session has expired. Please reload the page.', 'hcaptcha-for-forms-and-more' ) ); + } + + // Check for permissions. + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( esc_html__( 'You are not allowed to perform this action.', 'hcaptcha-for-forms-and-more' ) ); + } + + $version = Request::filter_input( INPUT_POST, 'version' ); + $version = $this->normalize_version( $version ); + $method = $this->version_to_method( $version ); + + if ( ! $version || ! method_exists( $this, $method ) ) { + wp_send_json_error( esc_html__( 'The requested version is not available.', 'hcaptcha-for-forms-and-more' ) ); + } + + ob_start(); + + $this->render_popup( $method, true ); + + $html = ob_get_clean(); + + wp_send_json_success( [ 'html' => $html ] ); + } + /** * Show a new features link in the update footer. * @@ -262,7 +278,7 @@ protected function render_popup( string $method, bool $display ): void {

- + render_version_selector( $version ); ?>

@@ -277,6 +293,42 @@ protected function render_popup( string $method, bool $display ): void { get_versions(); + + ?> + + + + + + method_to_version( $method ); + }, + $methods + ); + + usort( $versions, 'version_compare' ); + + return array_reverse( $versions ); + } + /** * Convert a version to a method name. * diff --git a/src/php/AutoVerify/AutoVerify.php b/src/php/AutoVerify/AutoVerify.php index 7a8107887..ec45f9e73 100644 --- a/src/php/AutoVerify/AutoVerify.php +++ b/src/php/AutoVerify/AutoVerify.php @@ -544,7 +544,7 @@ private function process_content( $content ): string { if ( preg_match_all( - '#
#', + '##', $content, $matches, PREG_PATTERN_ORDER diff --git a/src/php/BeaverBuilder/Base.php b/src/php/BeaverBuilder/Base.php index 300133741..bc1ae2255 100644 --- a/src/php/BeaverBuilder/Base.php +++ b/src/php/BeaverBuilder/Base.php @@ -44,14 +44,10 @@ protected function init_hooks(): void { * @noinspection PhpUnusedParameterInspection */ protected function add_hcap_form( string $out, $module ): string { - $form_id = false !== strpos( static::ACTION, 'login' ) ? 'login' : 'contact'; $args = [ 'action' => static::ACTION, 'name' => static::NONCE, - 'id' => [ - 'source' => HCaptcha::get_class_source( static::class ), - 'form_id' => $form_id, - ], + 'id' => $this->get_expected_id(), ]; $hcaptcha = '
' . HCaptcha::form( $args ) . '
'; $button_pattern = '
Request::filter_input( INPUT_POST, 'h-captcha-response' ), 'form_date_gmt' => $post->post_modified_gmt ?? null, 'data' => $this->get_data(), + 'expected_id' => $this->get_expected_id(), ]; } diff --git a/src/php/BeaverBuilder/Login.php b/src/php/BeaverBuilder/Login.php index eb27ce833..d852eea9a 100644 --- a/src/php/BeaverBuilder/Login.php +++ b/src/php/BeaverBuilder/Login.php @@ -11,7 +11,6 @@ namespace HCaptcha\BeaverBuilder; use FLBuilderModule; -use HCaptcha\Helpers\API; use WP_Error; use WP_User; @@ -65,7 +64,6 @@ public function add_beaver_builder_captcha( $out, FLBuilderModule $module ) { * @param string $password Password to check against the user. * * @return WP_User|WP_Error - * @noinspection PhpUnusedParameterInspection */ public function verify( $user, string $password ) { if ( ! doing_action( 'wp_ajax_nopriv_fl_builder_login_form_submit' ) ) { @@ -76,12 +74,6 @@ public function verify( $user, string $password ) { return $user; } - $error_message = API::verify_post_html( self::NONCE, self::ACTION ); - - if ( null === $error_message ) { - return $user; - } - - return new WP_Error( 'invalid_hcaptcha', $error_message, 400 ); + return $this->login_base_verify( $user, $password ); } } diff --git a/src/php/Blocksy/NewsletterSubscribe.php b/src/php/Blocksy/NewsletterSubscribe.php index 444640f3b..a56a02f2c 100644 --- a/src/php/Blocksy/NewsletterSubscribe.php +++ b/src/php/Blocksy/NewsletterSubscribe.php @@ -15,6 +15,11 @@ * Class `Newsletter Subscribe`. */ class NewsletterSubscribe { + /** + * Script handle. + */ + private const HANDLE = 'hcaptcha-blocksy'; + /** * Nonce action. */ @@ -51,6 +56,8 @@ private function init_hooks(): void { ); add_action( 'wp_head', [ $this, 'print_inline_styles' ] ); + add_action( 'wp_print_footer_scripts', [ $this, 'enqueue_scripts' ], 9 ); + add_filter( 'script_loader_tag', [ $this, 'add_type_module' ], 10, 3 ); } /** @@ -73,10 +80,7 @@ public function add_hcaptcha( $block_content, array $block, WP_Block $instance ) $args = [ 'action' => self::ACTION, 'name' => self::NONCE, - 'id' => [ - 'source' => HCaptcha::get_class_source( __CLASS__ ), - 'form_id' => 'newsletter-subscribe', - ], + 'id' => $this->get_expected_id(), ]; $search = 'form_shown ) { + return; + } + + $min = hcap_min_suffix(); + + wp_enqueue_script( + self::HANDLE, + HCAPTCHA_URL . "/assets/js/hcaptcha-blocksy$min.js", + [], + HCAPTCHA_VERSION, + true + ); + } + + /** + * Add the type="module" attribute to the script tag. + * + * @param string|mixed $tag Script tag. + * @param string $handle Script handle. + * @param string $src Script source. + * + * @return string + * @noinspection PhpUnusedParameterInspection + */ + public function add_type_module( $tag, string $handle, string $src ): string { + $tag = (string) $tag; + + if ( self::HANDLE !== $handle ) { + return $tag; + } + + return HCaptcha::add_type_module( $tag ); + } + /** * Print inline styles. * @@ -151,6 +196,7 @@ private function get_entry( array $form_data ): array { 'h-captcha-response' => $form_data['h-captcha-response'] ?? '', 'form_date_gmt' => $post->post_modified_gmt ?? null, 'data' => [], + 'expected_id' => $this->get_expected_id(), ]; foreach ( $form_data as $key => $value ) { @@ -165,4 +211,16 @@ private function get_entry( array $form_data ): array { return $entry; } + + /** + * Get expected hCaptcha widget id. + * + * @return array + */ + private function get_expected_id(): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => 'newsletter-subscribe', + ]; + } } diff --git a/src/php/Blocksy/Waitlist.php b/src/php/Blocksy/Waitlist.php index 493ddddd9..45867dcd8 100644 --- a/src/php/Blocksy/Waitlist.php +++ b/src/php/Blocksy/Waitlist.php @@ -16,6 +16,11 @@ * Class Waitlist. */ class Waitlist { + /** + * Script handle. + */ + private const HANDLE = 'hcaptcha-blocksy'; + /** * Nonce action. */ @@ -45,6 +50,8 @@ private function init_hooks(): void { add_filter( 'blocksy:ext:woocommerce-extra:waitlist:subscribe:validate', [ $this, 'verify' ], 10, 3 ); add_action( 'wp_head', [ $this, 'print_inline_styles' ] ); + add_action( 'wp_print_footer_scripts', [ $this, 'enqueue_scripts' ], 9 ); + add_filter( 'script_loader_tag', [ $this, 'add_type_module' ], 10, 3 ); } /** @@ -83,10 +90,7 @@ public function after_layer( $layer ): void { $args = [ 'action' => self::ACTION, 'name' => self::NONCE, - 'id' => [ - 'source' => HCaptcha::get_class_source( __CLASS__ ), - 'form_id' => $layer['__id'], - ], + 'id' => $this->get_expected_id(), ]; // Find the last $search string and insert hcaptcha before it. @@ -129,6 +133,47 @@ public function verify( $value, int $product_id, string $email ): ?WP_Error { return $value; } + /** + * Enqueue scripts. + * + * @return void + */ + public function enqueue_scripts(): void { + if ( ! hcaptcha()->form_shown ) { + return; + } + + $min = hcap_min_suffix(); + + wp_enqueue_script( + self::HANDLE, + HCAPTCHA_URL . "/assets/js/hcaptcha-blocksy$min.js", + [], + HCAPTCHA_VERSION, + true + ); + } + + /** + * Add the type="module" attribute to the script tag. + * + * @param string|mixed $tag Script tag. + * @param string $handle Script handle. + * @param string $src Script source. + * + * @return string + * @noinspection PhpUnusedParameterInspection + */ + public function add_type_module( $tag, string $handle, string $src ): string { + $tag = (string) $tag; + + if ( self::HANDLE !== $handle ) { + return $tag; + } + + return HCaptcha::add_type_module( $tag ); + } + /** * Print inline styles. * @@ -173,6 +218,7 @@ private function get_entry( array $form_data ): array { 'h-captcha-response' => $form_data['h-captcha-response'] ?? '', 'form_date_gmt' => $post->post_modified_gmt ?? null, 'data' => [], + 'expected_id' => $this->get_expected_id(), ]; foreach ( $form_data as $key => $value ) { @@ -187,4 +233,16 @@ private function get_entry( array $form_data ): array { return $entry; } + + /** + * Get expected hCaptcha widget id. + * + * @return array + */ + private function get_expected_id(): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => 'waitlist', + ]; + } } diff --git a/src/php/BuddyPress/CreateGroup.php b/src/php/BuddyPress/CreateGroup.php index d109f840f..8d8f638c5 100644 --- a/src/php/BuddyPress/CreateGroup.php +++ b/src/php/BuddyPress/CreateGroup.php @@ -110,6 +110,19 @@ private function get_entry( BP_Groups_Group $bp_group ): array { 'nonce_action' => self::ACTION, 'h-captcha-response' => Request::filter_input( INPUT_POST, 'h-captcha-response' ), 'data' => $this->get_data( $bp_group ), + 'expected_id' => $this->get_expected_id(), + ]; + } + + /** + * Get expected hCaptcha widget id. + * + * @return array + */ + private function get_expected_id(): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => 'create_group', ]; } diff --git a/src/php/BuddyPress/Register.php b/src/php/BuddyPress/Register.php index 48a3392c5..c77fbae75 100644 --- a/src/php/BuddyPress/Register.php +++ b/src/php/BuddyPress/Register.php @@ -79,7 +79,7 @@ public function add_captcha(): void { public function verify(): bool { global $bp; - $error_message = API::verify_post( self::NAME, self::ACTION ); + $error_message = API::verify( $this->get_entry() ); if ( null !== $error_message ) { $bp->signup->errors['hcaptcha_response_verify'] = $error_message; @@ -89,4 +89,29 @@ public function verify(): bool { return true; } + + /** + * Get entry. + * + * @return array + */ + private function get_entry(): array { + return [ + 'nonce_name' => self::NAME, + 'nonce_action' => self::ACTION, + 'expected_id' => $this->get_expected_id(), + ]; + } + + /** + * Get expected hCaptcha widget id. + * + * @return array + */ + private function get_expected_id(): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => 'register', + ]; + } } diff --git a/src/php/CoBlocks/Form.php b/src/php/CoBlocks/Form.php index dfd97cad2..4dc41a965 100644 --- a/src/php/CoBlocks/Form.php +++ b/src/php/CoBlocks/Form.php @@ -43,6 +43,13 @@ class Form { */ private ?string $error_message = null; + /** + * Current form id. + * + * @var int|string + */ + private $form_id = 0; + /** * Form constructor. */ @@ -151,6 +158,8 @@ public function render_block_data( $parsed_block, array $source_block ): array { * @noinspection PhpUnusedParameterInspection */ public function before_form_submit( array $post, array $atts ): void { + $this->form_id = $atts['formHash'] ?? $atts['form-hash'] ?? $post['form-hash'] ?? 0; + add_filter( 'pre_option_coblocks_google_recaptcha_site_key', '__return_true' ); add_filter( 'pre_option_coblocks_google_recaptcha_secret_key', '__return_true' ); @@ -225,6 +234,20 @@ public function print_inline_styles(): void { HCaptcha::css_display( $css ); } + /** + * Get expected hCaptcha widget id. + * + * @param int|string $form_id Form id. + * + * @return array + */ + private function get_expected_id( $form_id ): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => $this->form_id ?: $form_id, + ]; + } + /** * Get entry. * @@ -241,6 +264,7 @@ private function get_entry( array $form_data ): array { 'h-captcha-response' => $form_data['h-captcha-response'] ?? '', 'form_date_gmt' => $post->post_modified_gmt ?? null, 'data' => [], + 'expected_id' => $this->get_expected_id( $form_data['form-hash'] ?? 0 ), ]; $name = []; diff --git a/src/php/DelayedScript/DelayedScript.php b/src/php/DelayedScript/DelayedScript.php index 5af5991a7..df92814f4 100644 --- a/src/php/DelayedScript/DelayedScript.php +++ b/src/php/DelayedScript/DelayedScript.php @@ -29,13 +29,18 @@ class DelayedScript { /** * Create a delayed script. * - * @param string $js js code to wrap in setTimeout(). - * @param int $delay Delay in ms. Negative means no delay, wait for user interaction. + * @param string $js js code to wrap in setTimeout(). + * @param int $delay Delay in ms. Negative means no delay, wait for user interaction. + * @param string $delay_api_event Custom browser event name to wait for instead of default triggers. * * @return string * @noinspection JSUnusedAssignment */ - public static function create( string $js, int $delay = -1 ): string { + public static function create( string $js, int $delay = -1, string $delay_api_event = '' ): string { + if ( '' !== $delay_api_event ) { + return self::create_for_event( $js, $delay_api_event ); + } + /* language=JS */ $js = " ( () => { @@ -94,6 +99,49 @@ function scrollHandler() { return "\n"; } + /** + * Create a delayed script that waits for a custom event. + * + * @param string $js js code to wrap in a custom event listener. + * @param string $delay_api_event Custom browser event name to wait for. + * + * @return string + */ + private static function create_for_event( string $js, string $delay_api_event ): string { + $delay_api_event = wp_json_encode( + $delay_api_event, + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT + ); + + /* language=JS */ + $js = " + ( () => { + 'use strict'; + + // noinspection JSAnnotator + const delayApiEvent = $delay_api_event; + let loaded = false; + + function load() { + if ( loaded ) { + return; + } + + loaded = true; + document.removeEventListener( delayApiEvent, load ); + +$js + } + + document.addEventListener( 'hCaptchaBeforeAPI', function() { + document.addEventListener( delayApiEvent, load ); + } ); + } )(); +"; + + return "\n"; + } + /** * Enqueue a script or load it via observe() if the delay API selector is set. * @@ -297,12 +345,13 @@ private static function observe( string $selector, $handles ): void { /** * Launch script specified by a source url. * - * @param array $args Arguments. - * @param int $delay Delay in ms. Negative means no delay, wait for user interaction. + * @param array $args Arguments. + * @param int $delay Delay in ms. Negative means no delay, wait for user interaction. + * @param string $delay_api_event Custom browser event name to wait for instead of default triggers. * * @noinspection JSUnusedLocalSymbols */ - public static function launch( array $args, int $delay = -1 ): void { + public static function launch( array $args, int $delay = -1, string $delay_api_event = '' ): void { unset( $args['id'], $args['async'] ); /* language=JS */ @@ -335,7 +384,7 @@ public static function launch( array $args, int $delay = -1 ): void { $js = trim( $js, " \n\r" ); - echo self::create( $js, $delay ); + echo self::create( $js, $delay, $delay_api_event ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } diff --git a/src/php/DownloadManager/DownloadManager.php b/src/php/DownloadManager/DownloadManager.php index 9fcdc74f5..3ef61f8a3 100644 --- a/src/php/DownloadManager/DownloadManager.php +++ b/src/php/DownloadManager/DownloadManager.php @@ -9,6 +9,7 @@ use HCaptcha\Helpers\API; use HCaptcha\Helpers\HCaptcha; +use HCaptcha\Helpers\Request; /** * Class DownloadManager. @@ -52,7 +53,6 @@ public function init_hooks(): void { * @return string * @noinspection PhpUnusedParameterInspection * @noinspection HtmlUnknownAttribute - * @noinspection UnnecessaryCastingInspection */ public function add_hcaptcha( string $template, array $vars ): string { $form_id = 0; @@ -96,7 +96,7 @@ public function add_hcaptcha( string $template, array $vars ): string { */ public function verify( $package ): void { - $result = API::verify_post( self::NONCE, self::ACTION ); + $result = API::verify( $this->get_entry() ); if ( null === $result ) { return; @@ -112,6 +112,42 @@ public function verify( $package ): void { ); } + /** + * Get entry. + * + * @return array + */ + private function get_entry(): array { + return [ + 'nonce_name' => self::NONCE, + 'nonce_action' => self::ACTION, + 'expected_id' => $this->get_expected_id( $this->get_form_id() ), + ]; + } + + /** + * Get expected hCaptcha widget id. + * + * @param int $form_id Form id. + * + * @return array + */ + private function get_expected_id( int $form_id ): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => $form_id, + ]; + } + + /** + * Get form id. + * + * @return int + */ + private function get_form_id(): int { + return absint( Request::filter_input( INPUT_GET, 'wpdmdl' ) ); + } + /** * Print inline styles. * diff --git a/src/php/EssentialBlocks/Form.php b/src/php/EssentialBlocks/Form.php index d72ff39de..47bff0e23 100644 --- a/src/php/EssentialBlocks/Form.php +++ b/src/php/EssentialBlocks/Form.php @@ -85,11 +85,7 @@ public function add_hcaptcha( $block_content, array $block, WP_Block $instance ) return (string) $block_content; } - $form_id = 0; - - if ( preg_match( '/get_form_id_from_block_content( (string) $block_content ); $search = '
]*\beb-form-submit-button\b)[^>]*>/i', $block_content, $button ) && + preg_match( '/\bdata-id=(?:"([^"]+)"|\'([^\']+)\'|([^\s>]+))/i', $button[0], $m ) + ) { + return $m[1] ?: ( $m[2] ?: $m[3] ); + } + + if ( preg_match( '/get_entry( $form_data ) ); + $error_message = API::verify( $this->get_entry( $form_data, $this->get_form_id() ) ); if ( null !== $error_message ) { wp_send_json_error( $error_message ); @@ -124,20 +143,45 @@ public function verify(): void { /** * Get entry. * - * @param array $form_data Form data. + * @param array $form_data Form data. + * @param string $form_id Form id. * * @return array */ - private function get_entry( array $form_data ): array { + private function get_entry( array $form_data, string $form_id ): array { return [ 'nonce_name' => self::NONCE, 'nonce_action' => self::ACTION, 'h-captcha-response' => $form_data['h-captcha-response'] ?? '', 'post_data' => $form_data, 'data' => $this->get_data( $form_data ), + 'expected_id' => $this->get_expected_id( $form_id ), ]; } + /** + * Get expected hCaptcha widget id. + * + * @param string $form_id Form id. + * + * @return array + */ + private function get_expected_id( string $form_id ): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => $form_id, + ]; + } + + /** + * Get form id. + * + * @return string + */ + private function get_form_id(): string { + return (string) Request::filter_input( INPUT_POST, 'form_id' ); + } + /** * Get data for anti-spam checks. * diff --git a/src/php/FluentForm/Form.php b/src/php/FluentForm/Form.php index e80f837df..a1c9723cf 100644 --- a/src/php/FluentForm/Form.php +++ b/src/php/FluentForm/Form.php @@ -19,7 +19,6 @@ use HCaptcha\DelayedScript\DelayedScript; use HCaptcha\Helpers\API; use HCaptcha\Helpers\HCaptcha; -use HCaptcha\Helpers\Request; use HCaptcha\Helpers\Utils; use HCaptcha\Main; use stdClass; @@ -287,7 +286,11 @@ public function print_footer_scripts(): void { wp_deregister_script( self::FLUENT_FORMS_CONVERSATIONAL_HANDLE ); $hcap_form = $this->get_hcaptcha(); - $hcap_form = str_replace( 'class="h-captcha"', 'class=""', $hcap_form ); + $hcap_form = preg_replace( + '#class="[^"]*\bh-captcha\b[^"]*"#', + 'class=""', + $hcap_form + ); $hcap_form = '"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped @@ -489,10 +492,7 @@ protected function get_hcaptcha(): string { $args = [ 'action' => self::ACTION, 'name' => self::NONCE, - 'id' => [ - 'source' => HCaptcha::get_class_source( __CLASS__ ), - 'form_id' => $this->form_id, - ], + 'id' => $this->get_expected_id(), ]; return HCaptcha::form( $args ); @@ -589,13 +589,17 @@ private function remove_ff_hcaptcha(): void { * @noinspection PhpUndefinedMethodInspection */ private function get_entry( FluentForm $form ): array { - $post_data_str = Request::filter_input( INPUT_POST, 'data' ); + // Fluent Forms sends serialized form data; sanitizing the whole query string breaks encoded field values. + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $post_data_str = isset( $_POST['data'] ) ? wp_unslash( $_POST['data'] ) : ''; wp_parse_str( $post_data_str, $post_data ); $post_data = (array) $post_data; // The $post_data is filtered in the wp_parse_str() and can be anything. - $form_fields_json = $form->getAttributes()['form_fields'] ?? []; + $attributes = $form->getAttributes(); + $form_id = (int) ( $attributes['id'] ?? $form->id ?? 0 ); + $form_fields_json = $attributes['form_fields'] ?? []; $form_fields = Utils::json_decode_arr( $form_fields_json ); $fields = $form_fields['fields'] ?? []; @@ -611,6 +615,30 @@ private function get_entry( FluentForm $form ): array { 'form_date_gmt' => $form->updated_at ?? null, 'data' => $data, 'post_data' => $post_data, + 'expected_id' => $this->get_form_expected_id( $form_id ), + ]; + } + + /** + * Get expected hCaptcha widget id. + * + * @return array + */ + protected function get_expected_id(): array { + return $this->get_form_expected_id( $this->form_id ); + } + + /** + * Get expected hCaptcha widget id for the form. + * + * @param int $form_id Form id. + * + * @return array + */ + private function get_form_expected_id( int $form_id ): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => $form_id, ]; } @@ -699,7 +727,7 @@ private function resolve_field_type( array $node ): string { } /** - * Filter raw post_data and keep only keys that exist in the fields map. + * Filter raw post_data and keep only keys that exist in the field map. * Returns values enriched with type/element. * * @param array $post_data Raw parsed post data. diff --git a/src/php/FormidableForms/Form.php b/src/php/FormidableForms/Form.php index 9f1695266..fb8726477 100644 --- a/src/php/FormidableForms/Form.php +++ b/src/php/FormidableForms/Form.php @@ -116,7 +116,7 @@ public function add_hcaptcha( $html, array $field, array $atts ) { return $html; } - if ( ! preg_match( '#
#', (string) $html, $m ) ) { + if ( ! preg_match( '#
#', (string) $html, $m ) ) { return $html; } @@ -131,8 +131,11 @@ public function add_hcaptcha( $html, array $field, array $atts ) { ], ]; - $class = 'class="h-captcha"'; - $form = str_replace( $class, 'id="' . $div_id . '"' . $class, HCaptcha::form( $args ) ); + $form = preg_replace( + '#class="([^"]*\bh-captcha\b[^"]*)"#', + 'id="' . $div_id . '"class="$1"', + HCaptcha::form( $args ) + ); return str_replace( $captcha_div, $form, (string) $html ); } @@ -206,6 +209,7 @@ private function get_entry( array $values, array $validate_args ): array { 'nonce_action' => self::ACTION, 'h-captcha-response' => $values['h-captcha-response'] ?? '', 'form_date_gmt' => $form->created_at ?? null, + 'expected_id' => $this->get_expected_id( (int) $form_id ), ]; $fields = $validate_args['posted_fields'] ?? []; @@ -214,6 +218,20 @@ private function get_entry( array $values, array $validate_args ): array { return $entry; } + /** + * Get expected hCaptcha widget id. + * + * @param int $form_id Form id. + * + * @return array + */ + private function get_expected_id( int $form_id ): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => $form_id, + ]; + } + /** * Get data. * diff --git a/src/php/Forminator/Form.php b/src/php/Forminator/Form.php index ac06753b2..243d0f9a4 100644 --- a/src/php/Forminator/Form.php +++ b/src/php/Forminator/Form.php @@ -141,7 +141,7 @@ public function verify( $can_show, int $id, array $form_settings ) { } } - $error_message = API::verify( $this->get_entry( $module_object->fields ) ); + $error_message = API::verify( $this->get_entry( $module_object->fields, $id ) ); if ( null !== $error_message ) { return [ @@ -277,15 +277,26 @@ private function get_hcaptcha(): string { $args = [ 'action' => self::ACTION, 'name' => self::NONCE, - 'id' => [ - 'source' => HCaptcha::get_class_source( __CLASS__ ), - 'form_id' => $this->form_id, - ], + 'id' => $this->get_expected_id( $this->form_id ), ]; return HCaptcha::form( $args ); } + /** + * Get expected hCaptcha widget id. + * + * @param int $form_id Form id. + * + * @return array + */ + private function get_expected_id( int $form_id ): array { + return [ + 'source' => HCaptcha::get_class_source( __CLASS__ ), + 'form_id' => $form_id, + ]; + } + /** * Whether we are on the Forminator admin pages. * @@ -344,13 +355,13 @@ private function has_hcaptcha_field( array $form_fields ): bool { /** * Get entry. * - * @param array $fields Form data. + * @param array $fields Form data. + * @param int $form_id Form id. * * @return array */ - private function get_entry( array $fields ): array { - $form_id = (int) Request::filter_input( INPUT_POST, 'form_id' ); - $form = get_post( $form_id ); + private function get_entry( array $fields, int $form_id ): array { + $form = get_post( $form_id ); $entry = [ 'nonce_name' => self::NONCE, @@ -358,6 +369,7 @@ private function get_entry( array $fields ): array { 'h-captcha-response' => Request::filter_input( INPUT_POST, 'h-captcha-response' ) ?? '', 'form_date_gmt' => $form->post_modified_gmt ?? null, 'data' => [], + 'expected_id' => $this->get_expected_id( $form_id ), ]; $name = []; diff --git a/src/php/GiveWP/Base.php b/src/php/GiveWP/Base.php index 96bb9412f..f82a40faa 100644 --- a/src/php/GiveWP/Base.php +++ b/src/php/GiveWP/Base.php @@ -115,7 +115,7 @@ public function verify( $valid_data ): void { return; } - $error_message = API::verify_post( static::NAME, static::ACTION ); + $error_message = $this->verify_entry(); if ( null !== $error_message ) { give_set_error( 'invalid_hcaptcha', $error_message ); @@ -146,13 +146,7 @@ public function verify_block(): void { return; } - // phpcs:disable WordPress.Security.NonceVerification.Missing - $hcaptcha_response = isset( $_POST['h-captcha-response'] ) ? - filter_var( wp_unslash( $_POST['h-captcha-response'] ), FILTER_SANITIZE_FULL_SPECIAL_CHARS ) : - ''; - // phpcs:enable WordPress.Security.NonceVerification.Missing - - $error_message = API::verify_request( $hcaptcha_response ); + $error_message = $this->verify_entry( false ); if ( null === $error_message ) { return; @@ -166,6 +160,68 @@ public function verify_block(): void { ); } + /** + * Verify entry. + * + * @param bool $check_nonce Whether to check nonce. + * + * @return string|null + */ + private function verify_entry( bool $check_nonce = true ): ?string { + return API::verify( $this->get_entry( $check_nonce ) ); + } + + /** + * Get entry. + * + * @param bool $check_nonce Whether to check nonce. + * + * @return array + */ + private function get_entry( bool $check_nonce = true ): array { + return [ + 'nonce_name' => $check_nonce ? static::NAME : null, + 'nonce_action' => $check_nonce ? static::ACTION : null, + 'h-captcha-response' => Request::filter_input( INPUT_POST, 'h-captcha-response' ), + 'expected_id' => $this->get_expected_id( $this->get_form_id() ), + ]; + } + + /** + * Get expected hCaptcha widget id. + * + * @param int $form_id Form id. + * + * @return array + */ + private function get_expected_id( int $form_id ): array { + return [ + 'source' => HCaptcha::get_class_source( static::class ), + 'form_id' => $form_id, + ]; + } + + /** + * Get form id. + * + * @return int + */ + private function get_form_id(): int { + $form_id = absint( Request::filter_input( INPUT_POST, 'give-form-id' ) ); + + if ( $form_id ) { + return $form_id; + } + + $form_id = absint( Request::filter_input( INPUT_POST, 'formId' ) ); + + if ( $form_id ) { + return $form_id; + } + + return absint( Request::filter_input( INPUT_POST, 'form-id' ) ); + } + /** * Print footer scripts. * diff --git a/src/php/GravityForms/Field.php b/src/php/GravityForms/Field.php index 40db3a25d..262530ce4 100644 --- a/src/php/GravityForms/Field.php +++ b/src/php/GravityForms/Field.php @@ -14,6 +14,7 @@ use GFForms; use GFFormsModel; use HCaptcha\Helpers\HCaptcha; +use JsonException; /** * Class Field. @@ -216,9 +217,7 @@ public function get_field_input( $form, $value = '', $entry = null ) { $hcaptcha_size = hcaptcha()->settings()->get( 'size' ); $tabindex = GFCommon::$tab_index > 0 ? GFCommon::$tab_index++ : 0; $tabindex = 'invisible' === $hcaptcha_size ? -1 : $tabindex; - $search = 'class="h-captcha"'; - - $args = [ + $args = [ 'action' => Base::ACTION, 'name' => Base::NONCE, 'id' => [ @@ -227,9 +226,9 @@ public function get_field_input( $form, $value = '', $entry = null ) { ], ]; - return str_replace( - $search, - $search . ' id="' . $field_id . '" data-tabindex="' . $tabindex . '"', + return preg_replace( + '#class="([^"]*\bh-captcha\b[^"]*)"#', + 'class="$1" id="' . $field_id . '" data-tabindex="' . $tabindex . '"', HCaptcha::form( $args ) ); } @@ -246,7 +245,11 @@ public function disable_duplication( string $duplicate_field_link ): string { $action = rgpost( 'action' ); if ( 'rg_add_field' === $action ) { - $field = json_decode( rgpost( 'field' ), false ); + try { + $field = json_decode( rgpost( 'field' ), false, 512, JSON_THROW_ON_ERROR ); + } catch ( JsonException $e ) { + $field = null; + } } else { if ( ! preg_match( "/id='gfield_duplicate_(.*)?'/", $duplicate_field_link, $m ) ) { return $duplicate_field_link; diff --git a/src/php/GravityForms/Form.php b/src/php/GravityForms/Form.php index 0b5836935..420482643 100644 --- a/src/php/GravityForms/Form.php +++ b/src/php/GravityForms/Form.php @@ -141,7 +141,7 @@ public function gform_close( $form_string, $form ): string { } /** - * Filter hCaptcha from args on form. + * Filter hCaptcha from args on the form. * * @param array|mixed $args Form arguments. * @@ -186,7 +186,7 @@ public function verify( $validation_result, string $context ) { return $validation_result; } - $this->error_message = API::verify( $this->get_entry( $validation_result['form']['fields'] ) ); + $this->error_message = API::verify( $this->get_entry( $validation_result['form']['fields'], $this->get_form_id( $validation_result ) ) ); if ( null === $this->error_message ) { return $validation_result; @@ -422,20 +422,74 @@ private function has_hcaptcha( int $form_id ): bool { return false; } + /** + * Get form id from the validation result. + * + * @param array $validation_result Validation result. + * + * @return int + */ + private function get_form_id( array $validation_result ): int { + $form = (array) ( $validation_result['form'] ?? [] ); + + return (int) ( $form['id'] ?? 0 ); + } + + /** + * Get expected hCaptcha widget id. + * + * @param int $form_id Form id. + * + * @return array + */ + private function get_expected_id( int $form_id ): array { + return [ + 'source' => HCaptcha::get_class_source( $this->has_hcaptcha_field( $form_id ) ? Field::class : __CLASS__ ), + 'form_id' => $form_id, + ]; + } + + /** + * Whether the form has an embedded hCaptcha field. + * + * @param int $form_id Form id. + * + * @return bool + */ + private function has_hcaptcha_field( int $form_id ): bool { + $form = GFFormsModel::get_form_meta( $form_id ); + + if ( ! $form ) { + return false; + } + + foreach ( $form['fields'] as $field ) { + $type = $field->type ?? ''; + + if ( 'hcaptcha' === $type ) { + return true; + } + } + + return false; + } + /** * Get entry. * - * @param array $fields Form data. + * @param array $fields Form data. + * @param int $form_id Form id. * * @return array */ - private function get_entry( array $fields ): array { + private function get_entry( array $fields, int $form_id ): array { $entry = [ 'nonce_name' => self::NONCE, 'nonce_action' => self::ACTION, 'h-captcha-response' => Request::filter_input( INPUT_POST, 'h-captcha-response' ) ?? '', 'form_date_gmt' => null, // GF does not support form updated date. 'data' => [], + 'expected_id' => $this->get_expected_id( $form_id ), ]; $name = []; diff --git a/src/php/HTMLForms/Form.php b/src/php/HTMLForms/Form.php index 752a81840..c389f33f4 100644 --- a/src/php/HTMLForms/Form.php +++ b/src/php/HTMLForms/Form.php @@ -73,6 +73,7 @@ private function init_hooks(): void { * @noinspection PhpUnusedParameterInspection */ public function add_captcha( $html, \HTML_Forms\Form $form ): string { + $html = (string) $html; $form_id = (int) ( $form->ID ?? 0 ); // phpcs:disable WordPress.Security.NonceVerification.Recommended @@ -96,7 +97,7 @@ public function add_captcha( $html, \HTML_Forms\Form $form ): string { ], ]; - return (string) preg_replace( + return preg_replace( '/(\s*?markup, 'class="h-captcha"' ) ) { + if ( preg_match( '#class="[^"]*\bh-captcha\b[^"]*"#', $form->markup ) ) { return; } @@ -163,7 +164,7 @@ public function insert_post_data( $data, array $postarr, array $unsanitized_post $data['post_content'] = preg_replace( [ - '#\s*#', + '#\s*#', '##', '##', ], diff --git a/src/php/Helpers/API.php b/src/php/Helpers/API.php index 62cdc36eb..065fed0d4 100644 --- a/src/php/Helpers/API.php +++ b/src/php/Helpers/API.php @@ -278,6 +278,16 @@ public static function verify_request( $hcaptcha_response = null, array $entry = return self::filtered_result( $result, $error_codes ); } + $hcaptcha_response_sanitized = self::get_hcaptcha_response_sanitized( $hcaptcha_response ); + + // The hCaptcha response field is empty. + if ( '' === $hcaptcha_response_sanitized ) { + $result = hcap_get_error_messages()['empty']; + $error_codes = [ 'empty' ]; + + return self::filtered_result( $result, $error_codes ); + } + // Check the form submit time token. $check = self::check_fst_token(); @@ -295,16 +305,6 @@ public static function verify_request( $hcaptcha_response = null, array $entry = return self::filtered_result( $result, $error_codes ); } - $hcaptcha_response_sanitized = self::get_hcaptcha_response_sanitized( $hcaptcha_response ); - - // The hCaptcha response field is empty. - if ( '' === $hcaptcha_response_sanitized ) { - $result = hcap_get_error_messages()['empty']; - $error_codes = [ 'empty' ]; - - return self::filtered_result( $result, $error_codes ); - } - $params = self::get_params( $hcaptcha_response_sanitized ); return self::process_request( $params ); diff --git a/src/php/Helpers/HCaptcha.php b/src/php/Helpers/HCaptcha.php index df3712b92..51563ab0e 100644 --- a/src/php/Helpers/HCaptcha.php +++ b/src/php/Helpers/HCaptcha.php @@ -23,6 +23,11 @@ */ class HCaptcha { + /** + * API delayed widget class. + */ + public const HCAPTCHA_API_DELAYED_CLASS = 'hcaptcha-api-delayed'; + /** * Widget id. */ @@ -138,9 +143,11 @@ public static function form_display( array $args = [] ): void { return; } + $hcaptcha_classes = self::get_hcaptcha_classes(); + ?> get_delay_api_event() ) { + $classes[] = self::HCAPTCHA_API_DELAYED_CLASS; + } + + return $classes; + } + /** * Validate hCaptcha form arguments. * @@ -1095,7 +1117,7 @@ public static function add_type_module( string $tag ): string { ''; + loadFluentForm(); + } ); + + afterEach( () => { + $( document ).off( 'ajaxComplete' ); + if ( window.hCaptchaFluentForm ) { + window.removeEventListener( 'hCaptchaFetch:before', window.hCaptchaFluentForm.fetchBefore ); + window.removeEventListener( 'hCaptchaFetch:complete', window.hCaptchaFluentForm.fetchComplete ); + document.removeEventListener( 'hCaptchaLoaded', window.hCaptchaFluentForm.onHCaptchaLoaded ); + } + if ( originalMutationObserver ) { + window.MutationObserver = originalMutationObserver; + global.MutationObserver = originalMutationObserver; + } + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.HCaptchaFluentFormObject; + delete global.HCaptchaFluentFormObject; + delete window.hCaptchaFluentForm; + delete window.hcaptcha; + delete global.hcaptcha; + delete window.hCaptcha; + delete window.hCaptchaBindEvents; + delete window.__hcapFetchWrapped; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'installs fetch hooks and ajaxComplete handler', () => { + expect( helperMock.installFetchEvents ).toHaveBeenCalledTimes( 1 ); + + $( document ).trigger( 'ajaxComplete', [ {}, { data: 'action=other_action' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'ajaxComplete', [ {}, { data: 'action=fluentform_submit' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'adds hCaptcha data to FluentForm fetch request data', () => { + document.body.innerHTML = '
'; + const body = makeBody(); + + body.set( 'action', 'fluentform_submit' ); + body.set( 'form_id', '42' ); + body.set( 'data', 'name=value' ); + + const event = fetchEvent( 'hCaptchaFetch:before', body ); + + window.hCaptchaFluentForm.fetchBefore( event ); + + expect( helperMock.addHCaptchaData ).toHaveBeenCalledWith( + { + data: 'name=value&h-captcha-response=added-response', + }, + '', + 'hcaptcha_fluentform_nonce', + expect.objectContaining( { + 0: document.querySelector( '.ff_conv_app_42' ), + length: 1, + } ), + ); + expect( body.get( 'data' ) ).toBe( 'name=value&h-captcha-response=added-response' ); + expect( event.detail.args[ 1 ].body ).toBe( body ); + } ); + + test( 'uses empty fallbacks for missing FluentForm data and form id', () => { + document.body.innerHTML = '
'; + const body = makeBody(); + + body.set( 'action', 'fluentform_submit' ); + + window.hCaptchaFluentForm.fetchBefore( fetchEvent( 'hCaptchaFetch:before', body ) ); + + expect( helperMock.addHCaptchaData ).toHaveBeenCalledWith( + { + data: '&h-captcha-response=added-response', + }, + '', + 'hcaptcha_fluentform_nonce', + expect.objectContaining( { + 0: document.querySelector( '.ff_conv_app_' ), + length: 1, + } ), + ); + expect( body.get( 'data' ) ).toBe( '&h-captcha-response=added-response' ); + } ); + + test( 'supports URLSearchParams request bodies and skips missing FluentForm nodes', () => { + const body = makeBody( 'urlSearchParams' ); + + body.set( 'action', 'fluentform_submit' ); + body.set( 'form_id', 'missing' ); + body.set( 'data', 'name=value' ); + + window.hCaptchaFluentForm.fetchBefore( fetchEvent( 'hCaptchaFetch:before', body ) ); + + expect( helperMock.addHCaptchaData ).not.toHaveBeenCalled(); + expect( body.get( 'data' ) ).toBe( 'name=value' ); + } ); + + test( 'ignores FluentForm fetch requests that cannot be amended', () => { + expect( () => window.hCaptchaFluentForm.fetchBefore() ).not.toThrow(); + window.hCaptchaFluentForm.fetchBefore( new CustomEvent( 'hCaptchaFetch:before', { + detail: { + args: [ '/endpoint', { body: 'not-form-data' } ], + }, + } ) ); + + const otherAction = makeBody(); + otherAction.set( 'action', 'other_action' ); + window.hCaptchaFluentForm.fetchBefore( fetchEvent( 'hCaptchaFetch:before', otherAction ) ); + + expect( helperMock.addHCaptchaData ).not.toHaveBeenCalled(); + } ); + + test( 'rebinds hCaptcha after FluentForm fetch complete only', () => { + window.hCaptchaFluentForm.fetchComplete(); + window.hCaptchaFluentForm.fetchComplete( new CustomEvent( 'hCaptchaFetch:complete', { + detail: { + args: [ '/endpoint', { body: 'not-form-data' } ], + }, + } ) ); + + const otherAction = makeBody(); + otherAction.set( 'action', 'other_action' ); + window.hCaptchaFluentForm.fetchComplete( fetchEvent( 'hCaptchaFetch:complete', otherAction ) ); + + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + const body = makeBody( 'urlSearchParams' ); + body.set( 'action', 'fluentform_submit' ); + window.hCaptchaFluentForm.fetchComplete( fetchEvent( 'hCaptchaFetch:complete', body ) ); + + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'does nothing when conversational form is missing or already has hCaptcha', () => { + expect( () => window.hCaptchaFluentForm.onHCaptchaLoaded() ).not.toThrow(); + + document.body.innerHTML = '
'; + + window.hCaptchaFluentForm.onHCaptchaLoaded(); + + expect( document.getElementById( window.HCaptchaFluentFormObject.id ) ).toBeNull(); + } ); + + test( 'returns when conversational form disappears after the initial lookup', () => { + const originalQuerySelector = document.querySelector.bind( document ); + const fakeForm = { + querySelector: jest.fn( () => null ), + }; + let formLookupCount = 0; + + jest.spyOn( document, 'querySelector' ).mockImplementation( ( selector ) => { + if ( selector === '.ffc_conv_form' ) { + formLookupCount++; + + return formLookupCount === 1 ? fakeForm : null; + } + + return originalQuerySelector( selector ); + } ); + + expect( () => window.hCaptchaFluentForm.onHCaptchaLoaded() ).not.toThrow(); + } ); + + test( 'processes conversational form after footer appears and adds visible hCaptcha', async () => { + installMutationObserverMock(); + document.body.innerHTML = ` + +
+
+
+
+ + `; + + window.hCaptchaFluentForm.onHCaptchaLoaded(); + + mutationObservers[ 0 ].callback(); + expect( mutationObservers[ 0 ].disconnect ).not.toHaveBeenCalled(); + + expect( document.getElementById( window.HCaptchaFluentFormObject.id ).src ).toBe( window.HCaptchaFluentFormObject.url ); + expect( window.hcaptcha.render ).not.toBe( originalRender ); + + document.querySelector( '.ffc_conv_form' ).insertAdjacentHTML( 'beforeend', '' ); + mutationObservers[ 0 ].callback(); + await Promise.resolve(); + + const formObserver = mutationObservers[ 1 ]; + const step = document.querySelector( '.vff' ); + + expect( formObserver.observe ).toHaveBeenCalledWith( + document.querySelector( '.ffc_conv_form' ), + { + attributes: true, + attributeFilter: [ 'class' ], + subtree: true, + }, + ); + + formObserver.callback( [ { type: 'childList', target: step } ] ); + formObserver.callback( [ { type: 'attributes', attributeName: 'style', target: step } ] ); + formObserver.callback( [ { type: 'attributes', attributeName: 'class', target: step } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + step.classList.add( 'ffc_last_step' ); + formObserver.callback( [ { type: 'attributes', attributeName: 'class', target: step } ] ); + formObserver.callback( [ { type: 'attributes', attributeName: 'class', target: step } ] ); + + const visibleCaptcha = document.querySelector( '.ff-el-group .h-captcha-hidden' ); + + expect( visibleCaptcha ).toBeNull(); + expect( document.querySelector( '.ff-el-group .h-captcha' ) ).not.toBeNull(); + expect( document.querySelector( '.ff-el-group form .ff-btn' ) ).not.toBeNull(); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + + const callback = jest.fn(); + window.hcaptcha.render( '#container', { callback } ); + expect( originalRender ).toHaveBeenCalledWith( + '#container', + expect.objectContaining( { + callback, + size: 'invisible', + } ), + ); + } ); + + test( 'keeps conversational hCaptcha visible and normal sized when form owns captcha container', async () => { + installMutationObserverMock(); + document.body.innerHTML = ` + +
+
+ + +
+ `; + + window.hCaptchaFluentForm.onHCaptchaLoaded(); + await Promise.resolve(); + + expect( mutationObservers ).toHaveLength( 0 ); + + const callback = jest.fn(); + window.hcaptcha.render( '#hcaptcha-container', { callback } ); + + expect( originalRender ).toHaveBeenCalledWith( + '#hcaptcha-container', + expect.objectContaining( { + callback, + size: 'normal', + } ), + ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaFluentForm = existingApp; + + require( '../../../assets/js/hcaptcha-fluentform.js' ); + + expect( window.hCaptchaFluentForm ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-forminator.test.js b/tests/js/assets-js-files/hcaptcha-forminator.test.js new file mode 100644 index 000000000..033a89c0f --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-forminator.test.js @@ -0,0 +1,31 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Forminator', () => { + beforeEach( () => { + jest.resetModules(); + window.hCaptchaBindEvents = jest.fn(); + global.hCaptchaBindEvents = window.hCaptchaBindEvents; + + require( '../../../assets/js/hcaptcha-forminator.js' ); + } ); + + afterEach( () => { + $( document ).off( 'ajaxComplete' ); + delete window.hCaptchaBindEvents; + delete global.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'rebinds hCaptcha after Forminator submit ajax completes only', () => { + $( document ).trigger( 'ajaxComplete', [ {}, { data: 'action=other_action' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'ajaxComplete', [ {}, { data: 'action=forminator_submit_form_custom-forms' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-fst.test.js b/tests/js/assets-js-files/hcaptcha-fst.test.js new file mode 100644 index 000000000..de74fd476 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-fst.test.js @@ -0,0 +1,52 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +describe( 'hCaptcha FST', () => { + const waitForToken = () => new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + beforeEach( () => { + jest.resetModules(); + fetch.resetMocks(); + + window.HCaptchaFSTObject = { + ajaxUrl: 'https://test.test/wp-admin/admin-ajax.php', + issueTokenAction: 'hcaptcha-fst-issue-token', + issueTokenNonce: 'nonce', + }; + + document.body.className = 'page-id-123'; + document.body.innerHTML = ''; + } ); + + afterEach( () => { + delete window.HCaptchaFSTObject; + delete window.hCaptchaFST; + document.body.className = ''; + document.body.innerHTML = ''; + } ); + + test( 'refreshes token on init and after hCaptcha bind events', async () => { + fetch.mockResponse( + JSON.stringify( { + success: true, + data: { + token: 'new-token', + }, + } ), + ); + + require( '../../../assets/js/hcaptcha-fst.js' ); + await waitForToken(); + + expect( fetch ).toHaveBeenCalledTimes( 1 ); + expect( fetch.mock.calls[ 0 ][ 0 ] ).toBe( window.HCaptchaFSTObject.ajaxUrl ); + expect( fetch.mock.calls[ 0 ][ 1 ].body ).toContain( 'postId=123' ); + expect( document.querySelector( '[name="hcap_fst_token"]' ).value ).toBe( 'new-token' ); + + document.querySelector( '[name="hcap_fst_token"]' ).value = 'old-token'; + document.dispatchEvent( new CustomEvent( 'hCaptchaAfterBindEvents' ) ); + await waitForToken(); + + expect( fetch ).toHaveBeenCalledTimes( 2 ); + expect( document.querySelector( '[name="hcap_fst_token"]' ).value ).toBe( 'new-token' ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-givewp.test.js b/tests/js/assets-js-files/hcaptcha-givewp.test.js new file mode 100644 index 000000000..a92f3a95a --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-givewp.test.js @@ -0,0 +1,239 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +describe( 'hCaptcha GiveWP', () => { + let helperMock; + let originalMutationObserver; + let observers; + + function installMutationObserverMock() { + observers = []; + originalMutationObserver = window.MutationObserver; + window.MutationObserver = jest.fn( function( callback ) { + this.callback = callback; + this.observe = jest.fn(); + observers.push( this ); + } ); + global.MutationObserver = window.MutationObserver; + } + + function loadGiveWP( { hcaptchaForm = '
Captcha
' } = {} ) { + jest.resetModules(); + helperMock = { + installFetchEvents: jest.fn(), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + installMutationObserverMock(); + window.wp = { + hooks: { + addFilter: jest.fn(), + }, + domReady: jest.fn(), + }; + global.wp = window.wp; + window.HCaptchaGiveWPObject = { + hcaptchaForm: hcaptchaForm ? JSON.stringify( hcaptchaForm ) : hcaptchaForm, + }; + global.HCaptchaGiveWPObject = window.HCaptchaGiveWPObject; + window.hCaptchaBindEvents = jest.fn(); + delete window.hCaptchaGiveWP; + + require( '../../../assets/js/hcaptcha-givewp.js' ); + } + + function fetchEvent( eventName, resource, body ) { + return new CustomEvent( eventName, { + detail: { + args: [ + resource, + { body }, + ], + }, + } ); + } + + beforeEach( () => { + document.body.innerHTML = ''; + loadGiveWP(); + } ); + + afterEach( () => { + if ( window.hCaptchaGiveWP ) { + window.removeEventListener( 'hCaptchaFetch:before', window.hCaptchaGiveWP.fetchBefore ); + window.removeEventListener( 'hCaptchaFetch:complete', window.hCaptchaGiveWP.fetchComplete ); + } + window.MutationObserver = originalMutationObserver; + global.MutationObserver = originalMutationObserver; + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.hCaptchaGiveWP; + delete window.HCaptchaGiveWPObject; + delete global.HCaptchaGiveWPObject; + delete window.hCaptchaBindEvents; + delete window.wp; + delete global.wp; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'registers GiveWP ajax submit buttons and init hooks', () => { + const callback = window.wp.hooks.addFilter.mock.calls[ 0 ][ 2 ]; + const giveWrapper = document.createElement( 'div' ); + const otherWrapper = document.createElement( 'div' ); + const giveButton = document.createElement( 'button' ); + const otherButton = document.createElement( 'button' ); + + giveWrapper.classList.add( 'givewp-layouts' ); + giveWrapper.appendChild( giveButton ); + otherWrapper.appendChild( otherButton ); + + expect( helperMock.installFetchEvents ).toHaveBeenCalledTimes( 1 ); + expect( window.wp.domReady ).toHaveBeenCalledWith( window.hCaptchaGiveWP.insertCaptcha ); + expect( callback( false, giveButton ) ).toBe( true ); + expect( callback( false, otherButton ) ).toBe( false ); + expect( callback( true, otherButton ) ).toBe( true ); + } ); + + test( 'adds hCaptcha fields to GiveWP donate fetch body', () => { + document.body.innerHTML = ` +
+ + + + + + +
+ `; + const body = new FormData(); + const event = fetchEvent( 'hCaptchaFetch:before', '/wp-json/givewp/v3?givewp-route=donate', body ); + + window.hCaptchaGiveWP.fetchBefore( event ); + + expect( body.get( 'h-captcha-response' ) ).toBe( 'response-token' ); + expect( body.get( 'hcaptcha-widget-id' ) ).toBe( 'widget-id' ); + expect( body.get( 'hcaptcha_give_wp_form_nonce' ) ).toBe( 'nonce-value' ); + expect( body.get( 'hcap_fst_token' ) ).toBe( 'fst-token' ); + expect( body.get( 'hcap_hp_sig' ) ).toBe( 'hp-sig' ); + expect( body.get( 'hcap_hp_test' ) ).toBe( 'hp-value' ); + expect( event.detail.args[ 1 ].body ).toBe( body ); + } ); + + test( 'uses fetch body fallbacks when optional GiveWP inputs are missing values', () => { + const fakeForm = { + querySelector: jest.fn( ( selector ) => { + if ( selector === '[id^="hcap_hp_"]' ) { + return { id: 'hcap_hp_empty' }; + } + + return null; + } ), + }; + const body = new URLSearchParams(); + const getElementByIdSpy = jest.spyOn( document, 'getElementById' ).mockReturnValueOnce( fakeForm ); + + window.hCaptchaGiveWP.fetchBefore( fetchEvent( 'hCaptchaFetch:before', { url: '/wp-json/givewp/v3?givewp-route=donate' }, body ) ); + + expect( getElementByIdSpy ).toHaveBeenCalledWith( 'root-givewp-donation-form' ); + expect( body.get( 'h-captcha-response' ) ).toBe( 'undefined' ); + expect( body.get( 'hcap_hp_empty' ) ).toBe( '' ); + } ); + + test( 'ignores GiveWP fetches that cannot be amended', () => { + expect( () => window.hCaptchaGiveWP.fetchBefore() ).not.toThrow(); + window.hCaptchaGiveWP.fetchBefore( fetchEvent( 'hCaptchaFetch:before', '/endpoint?givewp-route=donate', 'not-form-data' ) ); + window.hCaptchaGiveWP.fetchBefore( fetchEvent( 'hCaptchaFetch:before', '/endpoint?givewp-route=other', new FormData() ) ); + window.hCaptchaGiveWP.fetchBefore( fetchEvent( 'hCaptchaFetch:before', 'http://[', new FormData() ) ); + window.hCaptchaGiveWP.fetchBefore( fetchEvent( 'hCaptchaFetch:before', {}, new FormData() ) ); + + expect( () => window.hCaptchaGiveWP.fetchBefore( fetchEvent( 'hCaptchaFetch:before', '/endpoint?givewp-route=donate', new FormData() ) ) ).not.toThrow(); + } ); + + test( 'rebinds hCaptcha after GiveWP donate fetch completes only', () => { + window.hCaptchaGiveWP.fetchComplete(); + window.hCaptchaGiveWP.fetchComplete( fetchEvent( 'hCaptchaFetch:complete', '/endpoint?givewp-route=other', new FormData() ) ); + window.hCaptchaGiveWP.fetchComplete( fetchEvent( 'hCaptchaFetch:complete', 'http://[', new FormData() ) ); + window.hCaptchaGiveWP.fetchComplete( fetchEvent( 'hCaptchaFetch:complete', {}, new FormData() ) ); + + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + window.hCaptchaGiveWP.fetchComplete( fetchEvent( 'hCaptchaFetch:complete', { url: '/endpoint?givewp-route=donate' }, new FormData() ) ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + + delete window.hCaptchaBindEvents; + expect( () => window.hCaptchaGiveWP.fetchComplete( fetchEvent( 'hCaptchaFetch:complete', '/endpoint?givewp-route=donate', new FormData() ) ) ).not.toThrow(); + } ); + + test( 'insertCaptcha returns when script data or target form is missing', () => { + window.HCaptchaGiveWPObject.hcaptchaForm = ''; + expect( () => window.hCaptchaGiveWP.insertCaptcha() ).not.toThrow(); + + window.HCaptchaGiveWPObject.hcaptchaForm = JSON.stringify( '
' ); + expect( () => window.hCaptchaGiveWP.insertCaptcha() ).not.toThrow(); + expect( observers ).toHaveLength( 0 ); + } ); + + test( 'insertCaptcha observes GiveWP form and handles mutation guard branches', () => { + document.body.innerHTML = '
'; + + window.hCaptchaGiveWP.insertCaptcha(); + expect( observers[ 0 ].observe ).toHaveBeenCalledWith( + document.getElementById( 'root-givewp-donation-form' ), + { + childList: true, + subtree: true, + }, + ); + + observers[ 0 ].callback( [ { type: 'attributes' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + observers[ 0 ].callback( [ { type: 'childList' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + document.getElementById( 'root-givewp-donation-form' ).innerHTML = '
'; + observers[ 0 ].callback( [ { type: 'childList' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + } ); + + test( 'insertCaptcha adds hCaptcha before submit section and before multistep submit button', () => { + document.body.innerHTML = ` +
+
+
+ `; + + window.hCaptchaGiveWP.insertCaptcha(); + observers[ 0 ].callback( [ { type: 'childList' } ] ); + + expect( document.querySelector( '#root-givewp-donation-form > section.givewp-layouts' ) ).not.toBeNull(); + expect( document.querySelector( '#root-givewp-donation-form' ).firstElementChild.classList.contains( 'givewp-layouts' ) ).toBe( true ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + + document.body.innerHTML = ` +
+
+
+ `; + observers = []; + window.hCaptchaGiveWP.insertCaptcha(); + observers[ 0 ].callback( [ { type: 'childList' } ] ); + + expect( document.querySelector( '#button-wrap' ).firstElementChild.classList.contains( 'givewp-layouts' ) ).toBe( true ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 2 ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaGiveWP = existingApp; + + require( '../../../assets/js/hcaptcha-givewp.js' ); + + expect( window.hCaptchaGiveWP ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-gravity-forms.test.js b/tests/js/assets-js-files/hcaptcha-gravity-forms.test.js new file mode 100644 index 000000000..5dbc634a3 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-gravity-forms.test.js @@ -0,0 +1,68 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Gravity Forms frontend', () => { + beforeEach( () => { + jest.resetModules(); + window.hCaptchaBindEvents = jest.fn(); + + require( '../../../assets/js/hcaptcha-gravity-forms.js' ); + } ); + + afterEach( () => { + $( document ).off( 'gform_post_render' ); + delete window.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'rebinds hCaptcha only for ajax-rendered Gravity Forms', () => { + document.body.innerHTML = ` + +
+ `; + + $( document ).trigger( 'gform_post_render', [ 1 ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'gform_post_render', [ 2 ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); +} ); + +describe( 'hCaptcha Gravity Forms null form branch', () => { + afterEach( () => { + global.jQuery = $; + window.jQuery = $; + delete window.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'returns when the form lookup fails', () => { + jest.resetModules(); + const onMock = jest.fn(); + const jQueryStub = jest.fn( ( selector ) => { + if ( selector === document ) { + return { + on: onMock, + }; + } + + return null; + } ); + + global.jQuery = jQueryStub; + window.jQuery = jQueryStub; + window.hCaptchaBindEvents = jest.fn(); + + require( '../../../assets/js/hcaptcha-gravity-forms.js' ); + + const handler = onMock.mock.calls[ 0 ][ 1 ]; + + expect( () => handler( {}, 10 ) ).not.toThrow(); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-helper.test.js b/tests/js/assets-js-files/hcaptcha-helper.test.js new file mode 100644 index 000000000..bbe23e119 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-helper.test.js @@ -0,0 +1,227 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import { helper } from '../../../assets/js/hcaptcha-helper.js'; + +describe( 'hCaptcha helper', () => { + let originalFetch; + let originalDispatchEvent; + let originalDefineProperty; + let originalURLSearchParams; + + beforeEach( () => { + originalFetch = window.fetch; + originalDispatchEvent = window.dispatchEvent; + originalDefineProperty = Object.defineProperty; + originalURLSearchParams = global.URLSearchParams; + delete window.__hcapFetchWrapped; + helper.params = null; + } ); + + afterEach( () => { + window.fetch = originalFetch; + window.dispatchEvent = originalDispatchEvent; + Object.defineProperty = originalDefineProperty; + global.URLSearchParams = originalURLSearchParams; + delete window.__hcapFetchWrapped; + jest.restoreAllMocks(); + } ); + + test( 'constructor initializes params to null', () => { + expect( new helper().params ).toBeNull(); + } ); + + test( 'gets action values from supported option data shapes', () => { + const formData = new FormData(); + formData.append( 'action', 'form-action' ); + + expect( helper.getAction( { data: formData }, 'action' ) ).toBe( 'form-action' ); + expect( helper.getAction( { data: new FormData() }, 'action' ) ).toBe( '' ); + expect( helper.getAction( { data: { action: 'object-action' } }, 'action' ) ).toBe( 'object-action' ); + expect( helper.getAction( { data: {} }, 'action' ) ).toBe( '' ); + expect( helper.getAction( { data: 12 }, 'action' ) ).toBe( '' ); + expect( helper.getAction( { data: '?action=query-action' }, 'action' ) ).toBe( 'query-action' ); + } ); + + test( 'falls back to empty params when query parsing throws', () => { + global.URLSearchParams = jest + .fn() + .mockImplementationOnce( () => { + throw new Error( 'Bad query.' ); + } ) + .mockImplementation( ( value ) => new originalURLSearchParams( value ) ); + + expect( helper.getAction( { data: 'action=broken' }, 'action' ) ).toBe( '' ); + expect( helper.params.get( 'action' ) ).toBe( null ); + } ); + + test( 'checks matching actions', () => { + expect( helper.checkAction( { data: 'action=save' }, 'action', 'save' ) ).toBe( true ); + expect( helper.checkAction( { data: 'action=skip' }, 'action', 'save' ) ).toBe( false ); + } ); + + test( 'extracts hCaptcha data from DOM, jQuery wrappers, and empty roots', () => { + document.body.innerHTML = ` +
+ + + + + + +
+ `; + + const form = document.querySelector( 'form' ); + const data = helper.getHCaptchaData( form, 'nonce_name' ); + + expect( data ).toMatchObject( + { + 'h-captcha-response': 'token', + 'hcaptcha-widget-id': 'widget-id', + nonce_name: 'nonce', + hcap_hp_test: 'hp-value', + hcap_hp_sig: 'sig', + hcap_fst_token: '', + }, + ); + + expect( helper.getHCaptchaData( [ form ], 'nonce_name' ) ).toMatchObject( data ); + expect( helper.getHCaptchaData( [], 'nonce_name' )[ 'h-captcha-response' ] ).toBe( 'token' ); + expect( helper.getHCaptchaData( null, 'nonce_name' )[ 'h-captcha-response' ] ).toBe( 'token' ); + expect( helper.getHCaptchaData( {}, 'nonce_name' )[ 'h-captcha-response' ] ).toBe( '' ); + expect( helper.getHCaptchaData( document.createElement( 'div' ), 'nonce_name' ).nonce_name ).toBe( '' ); + + const fakeAttributeRoot = { + querySelector: jest.fn( ( selector ) => selector.includes( 'h-captcha-response' ) ? { + getAttribute: jest.fn(), + } : null ), + }; + expect( helper.getHCaptchaData( fakeAttributeRoot, 'nonce_name' )[ 'h-captcha-response' ] ).toBe( '' ); + } ); + + test( 'adds missing hCaptcha data only for matching actions', () => { + document.body.innerHTML = ` +
+ + + + + + +
+ `; + + const options = { + data: 'action=expected&hcaptcha-widget-id=already-there', + }; + + helper.addHCaptchaData( + options, + 'expected', + 'nonce_name', + document.getElementById( 'root' ), + ); + + expect( options.data ).toContain( 'h-captcha-response=response' ); + expect( options.data ).toContain( 'nonce_name=nonce' ); + expect( options.data ).toContain( 'hcap_hp_id=hp' ); + expect( options.data ).toContain( 'hcap_hp_sig=sig' ); + expect( options.data ).toContain( 'hcap_fst_token=token' ); + expect( options.data ).toContain( 'hcaptcha-widget-id=already-there' ); + expect( options.data ).not.toContain( 'hcaptcha-widget-id=widget' ); + + const skippedOptions = { + data: 'action=other', + }; + + helper.addHCaptchaData( + skippedOptions, + 'expected', + 'nonce_name', + document.getElementById( 'root' ), + ); + + expect( skippedOptions.data ).toBe( 'action=other' ); + } ); + + test( 'does not install fetch events when fetch is unavailable or already wrapped', () => { + window.fetch = undefined; + + helper.installFetchEvents(); + + expect( window.fetch ).toBeUndefined(); + + const fetchMock = jest.fn(); + window.fetch = fetchMock; + window.__hcapFetchWrapped = true; + + helper.installFetchEvents(); + + expect( window.fetch ).toBe( fetchMock ); + } ); + + test( 'wraps fetch and dispatches success and complete events', async () => { + const response = { + clone: jest.fn( () => 'cloned-response' ), + }; + const fetchPromise = Promise.resolve( response ); + const fetchMock = jest.fn( () => fetchPromise ); + const events = []; + + window.fetch = fetchMock; + window.dispatchEvent = jest.fn( ( event ) => events.push( event ) ); + + helper.installFetchEvents(); + const returnedPromise = window.fetch( '/endpoint', { method: 'POST' } ); + + expect( returnedPromise ).toBe( fetchPromise ); + await returnedPromise; + await Promise.resolve(); + await Promise.resolve(); + + expect( fetchMock ).toHaveBeenCalledWith( '/endpoint', { method: 'POST' } ); + expect( window.__hcapFetchWrapped ).toBe( true ); + expect( events.map( ( event ) => event.type ) ).toEqual( + [ 'hCaptchaFetch:before', 'hCaptchaFetch:success', 'hCaptchaFetch:complete' ], + ); + expect( events[ 1 ].detail.response ).toBe( 'cloned-response' ); + } ); + + test( 'wraps fetch and dispatches error events', async () => { + const error = new Error( 'Fetch failed.' ); + const fetchPromise = Promise.reject( error ); + const events = []; + + window.fetch = jest.fn( () => fetchPromise ); + window.dispatchEvent = jest.fn( ( event ) => events.push( event ) ); + + helper.installFetchEvents(); + + await expect( window.fetch( '/endpoint' ) ).rejects.toThrow( 'Fetch failed.' ); + await Promise.resolve(); + + expect( events.map( ( event ) => event.type ) ).toEqual( + [ 'hCaptchaFetch:before', 'hCaptchaFetch:error', 'hCaptchaFetch:complete' ], + ); + expect( events[ 1 ].detail.error ).toBe( error ); + } ); + + test( 'keeps fetch working when event dispatch or defineProperty fail', async () => { + const fetchPromise = Promise.resolve( { clone: jest.fn() } ); + + window.fetch = jest.fn( () => fetchPromise ); + window.dispatchEvent = jest.fn( () => { + throw new Error( 'Dispatch blocked.' ); + } ); + Object.defineProperty = jest.fn( () => { + throw new Error( 'defineProperty blocked.' ); + } ); + + helper.installFetchEvents(); + + await expect( window.fetch( '/endpoint' ) ).resolves.toBeDefined(); + await Promise.resolve(); + + expect( window.__hcapFetchWrapped ).toBe( true ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-icegram-express.test.js b/tests/js/assets-js-files/hcaptcha-icegram-express.test.js new file mode 100644 index 000000000..26f07f6f0 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-icegram-express.test.js @@ -0,0 +1,75 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Icegram Express', () => { + beforeEach( () => { + jest.resetModules(); + window.HCaptchaIcegramExpressObject = { + hCaptchaWidgets: JSON.stringify( { + 10: '
Captcha
', + } ), + }; + global.HCaptchaIcegramExpressObject = window.HCaptchaIcegramExpressObject; + delete window.hCaptchaIcegramExpress; + } ); + + afterEach( () => { + $( window ).off( 'init.icegram' ); + delete window.HCaptchaIcegramExpressObject; + delete global.HCaptchaIcegramExpressObject; + delete window.hCaptchaIcegramExpress; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'initializes immediately when Icegram container already exists', () => { + document.body.innerHTML = ` +
+
+
+
+
+
+
+
+ `; + + require( '../../../assets/js/hcaptcha-icegram-express.js' ); + + expect( document.querySelectorAll( '.h-captcha' ) ).toHaveLength( 1 ); + expect( document.querySelectorAll( '.ig_clear_fix' ) ).toHaveLength( 2 ); + expect( document.querySelector( '[data-form-id="20"] .h-captcha' ) ).toBeNull(); + } ); + + test( 'waits for init.icegram when Icegram container is not ready yet', () => { + document.body.innerHTML = ` +
+
+
+ `; + + require( '../../../assets/js/hcaptcha-icegram-express.js' ); + expect( document.querySelector( '.h-captcha' ) ).toBeNull(); + + $( window ).trigger( 'init.icegram' ); + + expect( document.querySelector( '.h-captcha' ) ).not.toBeNull(); + } ); + + test( 'reuses existing app object', () => { + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaIcegramExpress = existingApp; + + require( '../../../assets/js/hcaptcha-icegram-express.js' ); + + expect( window.hCaptchaIcegramExpress ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-jetpack.test.js b/tests/js/assets-js-files/hcaptcha-jetpack.test.js new file mode 100644 index 000000000..02e5aa0bf --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-jetpack.test.js @@ -0,0 +1,77 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +describe( 'hCaptcha Jetpack frontend', () => { + let helperMock; + + function loadJetpack() { + jest.resetModules(); + helperMock = { + installFetchEvents: jest.fn(), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + window.hCaptchaBindEvents = jest.fn(); + delete window.hCaptchaJetpack; + + require( '../../../assets/js/hcaptcha-jetpack.js' ); + } + + function fetchCompleteEvent( body ) { + return new CustomEvent( 'hCaptchaFetch:complete', { + detail: { + args: [ + '/wp-admin/admin-ajax.php', + { body }, + ], + }, + } ); + } + + beforeEach( () => { + loadJetpack(); + } ); + + afterEach( () => { + if ( window.hCaptchaJetpack ) { + window.removeEventListener( 'hCaptchaFetch:complete', window.hCaptchaJetpack.fetchComplete ); + } + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.hCaptchaJetpack; + delete window.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'installs fetch events and rebinds after Jetpack form fetch complete only', () => { + expect( helperMock.installFetchEvents ).toHaveBeenCalledTimes( 1 ); + + window.hCaptchaJetpack.fetchComplete(); + window.hCaptchaJetpack.fetchComplete( fetchCompleteEvent( 'not-form-data' ) ); + + const otherAction = new FormData(); + otherAction.set( 'action', 'other_action' ); + window.hCaptchaJetpack.fetchComplete( fetchCompleteEvent( otherAction ) ); + + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + const body = new FormData(); + body.set( 'action', 'grunion-contact-form' ); + window.hCaptchaJetpack.fetchComplete( fetchCompleteEvent( body ) ); + + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaJetpack = existingApp; + + require( '../../../assets/js/hcaptcha-jetpack.js' ); + + expect( window.hCaptchaJetpack ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-kadence-advanced.test.js b/tests/js/assets-js-files/hcaptcha-kadence-advanced.test.js new file mode 100644 index 000000000..a78dda40f --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-kadence-advanced.test.js @@ -0,0 +1,81 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +describe( 'hCaptcha Kadence Advanced frontend', () => { + let helperMock; + + function loadKadenceAdvanced() { + jest.resetModules(); + helperMock = { + installFetchEvents: jest.fn(), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + window.hCaptchaBindEvents = jest.fn(); + delete window.hCaptchaKadenceAdvanced; + + require( '../../../assets/js/hcaptcha-kadence-advanced.js' ); + } + + function fetchCompleteEvent( body ) { + return new CustomEvent( 'hCaptchaFetch:complete', { + detail: { + args: [ + '/wp-admin/admin-ajax.php', + { body }, + ], + }, + } ); + } + + beforeEach( () => { + loadKadenceAdvanced(); + } ); + + afterEach( () => { + if ( window.hCaptchaKadenceAdvanced ) { + window.removeEventListener( 'hCaptchaFetch:complete', window.hCaptchaKadenceAdvanced.fetchComplete ); + } + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.hCaptchaKadenceAdvanced; + delete window.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'installs fetch events and rebinds after Kadence advanced fetch complete only', () => { + expect( helperMock.installFetchEvents ).toHaveBeenCalledTimes( 1 ); + + window.hCaptchaKadenceAdvanced.fetchComplete(); + window.hCaptchaKadenceAdvanced.fetchComplete( fetchCompleteEvent( 'not-form-data' ) ); + + const otherAction = new FormData(); + otherAction.set( 'action', 'other_action' ); + window.hCaptchaKadenceAdvanced.fetchComplete( fetchCompleteEvent( otherAction ) ); + + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + const formData = new FormData(); + formData.set( 'action', 'kb_process_advanced_form_submit' ); + window.hCaptchaKadenceAdvanced.fetchComplete( fetchCompleteEvent( formData ) ); + + const params = new URLSearchParams(); + params.set( 'action', 'kb_process_advanced_form_submit' ); + window.hCaptchaKadenceAdvanced.fetchComplete( fetchCompleteEvent( params ) ); + + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 2 ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaKadenceAdvanced = existingApp; + + require( '../../../assets/js/hcaptcha-kadence-advanced.js' ); + + expect( window.hCaptchaKadenceAdvanced ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-kadence.test.js b/tests/js/assets-js-files/hcaptcha-kadence.test.js new file mode 100644 index 000000000..6e25d671d --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-kadence.test.js @@ -0,0 +1,102 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +describe( 'hCaptcha Kadence frontend', () => { + let OriginalXMLHttpRequest; + let originalSendMock; + + function installXHRMock() { + OriginalXMLHttpRequest = window.XMLHttpRequest; + originalSendMock = jest.fn(); + + function MockXMLHttpRequest() { + this.onreadystatechange = null; + this.readyState = 0; + } + + MockXMLHttpRequest.DONE = 4; + MockXMLHttpRequest.prototype.send = originalSendMock; + window.XMLHttpRequest = MockXMLHttpRequest; + global.XMLHttpRequest = MockXMLHttpRequest; + } + + function loadKadence() { + jest.resetModules(); + installXHRMock(); + window.wp = { + hooks: { + addFilter: jest.fn(), + }, + }; + global.wp = window.wp; + window.hCaptchaBindEvents = jest.fn(); + global.hCaptchaBindEvents = window.hCaptchaBindEvents; + + require( '../../../assets/js/hcaptcha-kadence.js' ); + } + + beforeEach( () => { + loadKadence(); + } ); + + afterEach( () => { + window.XMLHttpRequest = OriginalXMLHttpRequest; + global.XMLHttpRequest = OriginalXMLHttpRequest; + delete window.wp; + delete global.wp; + delete window.hCaptchaBindEvents; + delete global.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'registers Kadence submit selectors and ajax submit button detection', () => { + const selectorFilter = window.wp.hooks.addFilter.mock.calls[ 0 ][ 2 ]; + const ajaxFilter = window.wp.hooks.addFilter.mock.calls[ 1 ][ 2 ]; + const submitButton = document.createElement( 'button' ); + const otherButton = document.createElement( 'button' ); + + submitButton.classList.add( 'kb-forms-submit' ); + + expect( selectorFilter( 'button[type="submit"]' ) ).toBe( 'button[type="submit"], button.kb-forms-submit' ); + expect( ajaxFilter( false, submitButton ) ).toBe( true ); + expect( ajaxFilter( false, otherButton ) ).toBe( false ); + expect( ajaxFilter( true, otherButton ) ).toBe( true ); + } ); + + test( 'ignores XHR sends without hCaptcha response payload', () => { + const xhr = new XMLHttpRequest(); + + xhr.send( 'field=value' ); + xhr.send( new FormData() ); + + expect( originalSendMock ).not.toHaveBeenCalled(); + } ); + + test( 'wraps matching XHR state changes and calls original callback', () => { + const xhr = new XMLHttpRequest(); + const originalStateChange = jest.fn(); + + xhr.onreadystatechange = originalStateChange; + xhr.send( 'h-captcha-response=token' ); + expect( originalSendMock ).toHaveBeenCalledWith( 'h-captcha-response=token' ); + + xhr.readyState = 3; + xhr.onreadystatechange( 'progress' ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + expect( originalStateChange ).toHaveBeenCalledWith( 'progress' ); + + xhr.readyState = XMLHttpRequest.DONE; + xhr.onreadystatechange( 'done' ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + expect( originalStateChange ).toHaveBeenCalledWith( 'done' ); + } ); + + test( 'wraps matching XHR state changes without original callback', () => { + const xhr = new XMLHttpRequest(); + + xhr.send( 'h-captcha-response=token' ); + xhr.readyState = XMLHttpRequest.DONE; + + expect( () => xhr.onreadystatechange() ).not.toThrow(); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); +} ); 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 new file mode 100644 index 000000000..18512b244 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-login-signup-popup.test.js @@ -0,0 +1,29 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Login Signup Popup', () => { + beforeEach( () => { + jest.resetModules(); + window.hCaptchaBindEvents = jest.fn(); + + require( '../../../assets/js/hcaptcha-login-signup-popup.js' ); + } ); + + afterEach( () => { + $( document ).off( 'ajaxSuccess' ); + delete window.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'rebinds hCaptcha after login/signup popup ajax success only', () => { + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'action=other_action' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'xoo_el_form_action=login' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-mailpoet.test.js b/tests/js/assets-js-files/hcaptcha-mailpoet.test.js new file mode 100644 index 000000000..7fc658265 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-mailpoet.test.js @@ -0,0 +1,180 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha MailPoet', () => { + let originalAjaxPrefilter; + let ajaxPrefilterCallback; + + function loadMailPoet() { + jest.resetModules(); + originalAjaxPrefilter = $.ajaxPrefilter; + $.ajaxPrefilter = jest.fn( ( callback ) => { + ajaxPrefilterCallback = callback; + } ); + window.hCaptchaBindEvents = jest.fn(); + delete window.hCaptchaMailPoet; + + require( '../../../assets/js/hcaptcha-mailpoet.js' ); + } + + function paramsFrom( options ) { + return new URLSearchParams( options.data ); + } + + beforeEach( () => { + document.body.innerHTML = ''; + ajaxPrefilterCallback = null; + loadMailPoet(); + } ); + + afterEach( () => { + $( document ).off( 'ajaxComplete' ); + $.ajaxPrefilter = originalAjaxPrefilter; + delete window.hCaptchaMailPoet; + delete window.hCaptchaBindEvents; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'registers ajax prefilter and rebinds after MailPoet ajax complete only', () => { + expect( $.ajaxPrefilter ).toHaveBeenCalledWith( expect.any( Function ) ); + + $( document ).trigger( 'ajaxComplete', [ {}, {} ] ); + $( document ).trigger( 'ajaxComplete', [ {}, { data: { action: 'mailpoet' } } ] ); + $( document ).trigger( 'ajaxComplete', [ {}, { data: 'action=other_action' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'ajaxComplete', [ {}, { data: 'action=mailpoet' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'ignores ajax prefilter data that is not MailPoet form data', () => { + const missingData = {}; + const objectData = { data: { action: 'mailpoet' } }; + const otherAction = { data: 'action=other_action' }; + + ajaxPrefilterCallback( missingData ); + ajaxPrefilterCallback( objectData ); + ajaxPrefilterCallback( otherAction ); + + expect( missingData.data ).toBeUndefined(); + expect( objectData.data ).toEqual( { action: 'mailpoet' } ); + expect( otherAction.data ).toBe( 'action=other_action' ); + } ); + + test( 'adds hCaptcha values from a MailPoet form id lookup', () => { + document.body.innerHTML = ` +
+ + + + + + + +
+ `; + const options = { + data: 'action=mailpoet&data%5Bform_id%5D=12', + }; + + ajaxPrefilterCallback( options ); + + const params = paramsFrom( options ); + + expect( params.get( 'h-captcha-response' ) ).toBe( 'response-token' ); + expect( params.get( 'hcaptcha-widget-id' ) ).toBe( 'widget-id' ); + expect( params.get( 'hcaptcha_mailpoet_nonce' ) ).toBe( 'nonce-value' ); + expect( params.get( 'hcap_fst_token' ) ).toBe( 'fst-token' ); + expect( params.get( 'hcap_hp_sig' ) ).toBe( 'hp-sig' ); + expect( params.get( 'hcap_hp_test' ) ).toBe( 'hp-value' ); + } ); + + test( 'falls back to context form and empty field values', () => { + document.body.innerHTML = '
'; + const options = { + context: document.getElementById( 'submit-button' ), + data: 'action=mailpoet', + }; + + ajaxPrefilterCallback( options ); + + const params = paramsFrom( options ); + + expect( params.get( 'h-captcha-response' ) ).toBe( '' ); + expect( params.get( 'hcaptcha-widget-id' ) ).toBe( '' ); + expect( params.get( 'hcaptcha_mailpoet_nonce' ) ).toBe( '' ); + expect( params.get( 'hcap_fst_token' ) ).toBe( '' ); + expect( params.get( 'hcap_hp_sig' ) ).toBe( '' ); + } ); + + test( 'falls back to active element form and returns when no form can be resolved', () => { + document.body.innerHTML = '
'; + const activeOptions = { + data: 'action=mailpoet', + }; + + document.getElementById( 'active-input' ).focus(); + ajaxPrefilterCallback( activeOptions ); + expect( paramsFrom( activeOptions ).get( 'h-captcha-response' ) ).toBe( 'active-response' ); + + document.getElementById( 'outside' ).focus(); + const missingFormOptions = { + data: 'action=mailpoet', + }; + ajaxPrefilterCallback( missingFormOptions ); + + expect( missingFormOptions.data ).toBe( 'action=mailpoet' ); + } ); + + test( 'uses empty hcap hp fallbacks when the hcap input has no jQuery id or value', () => { + document.body.innerHTML = ` +
+ + +
+ `; + const options = { + data: 'action=mailpoet&data%5Bform_id%5D=12', + }; + const originalAttr = $.fn.attr; + const originalVal = $.fn.val; + + jest.spyOn( $.fn, 'attr' ).mockImplementation( function( name ) { + if ( name === 'id' && this.is( '[id^="hcap_hp_"]' ) ) { + return undefined; + } + + return originalAttr.apply( this, arguments ); + } ); + jest.spyOn( $.fn, 'val' ).mockImplementation( function() { + if ( this.is( '[id^="hcap_hp_"]' ) ) { + return undefined; + } + + return originalVal.apply( this, arguments ); + } ); + + ajaxPrefilterCallback( options ); + + expect( paramsFrom( options ).get( '' ) ).toBe( '' ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaMailPoet = existingApp; + + require( '../../../assets/js/hcaptcha-mailpoet.js' ); + + expect( window.hCaptchaMailPoet ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-otter.test.js b/tests/js/assets-js-files/hcaptcha-otter.test.js new file mode 100644 index 000000000..80153e5bc --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-otter.test.js @@ -0,0 +1,88 @@ +describe( 'hCaptcha Otter', () => { + beforeEach( () => { + jest.resetModules(); + + global.wp = { + hooks: { + addFilter: jest.fn(), + }, + }; + window.wp = global.wp; + window.hCaptchaBindEvents = jest.fn(); + document.body.innerHTML = ` +
+ + + + + + +
+ `; + + require( '../../../assets/js/hcaptcha-otter.js' ); + } ); + + afterEach( () => { + window.removeEventListener( 'hCaptchaFetch:before', window.hCaptchaOtter.fetchBefore ); + window.removeEventListener( 'hCaptchaFetch:complete', window.hCaptchaOtter.fetchComplete ); + delete window.hCaptchaOtter; + delete window.hCaptchaBindEvents; + delete window.__hcapFetchWrapped; + delete window.wp; + delete global.wp; + document.body.innerHTML = ''; + } ); + + test.each( [ + 'fc0b7800', + 'wp-block-themeisle-blocks-form-fc0b7800', + ] )( 'adds hCaptcha fields to Otter form_data for formId %s', ( formId ) => { + const body = new FormData(); + + body.set( + 'form_data', + JSON.stringify( + { + payload: { + formId, + }, + }, + ), + ); + + window.dispatchEvent( + new CustomEvent( 'hCaptchaFetch:before', { + detail: { + args: [ + '/otter/v1/form/frontend', + { body }, + ], + }, + } ), + ); + + const formData = JSON.parse( body.get( 'form_data' ) ); + + expect( formData[ 'h-captcha-response' ] ).toBe( 'response-token' ); + expect( formData[ 'hcaptcha-widget-id' ] ).toBe( 'widget-id' ); + expect( formData.hcaptcha_otter_nonce ).toBe( 'nonce-value' ); + expect( formData.hcap_fst_token ).toBe( 'fst-token' ); + expect( formData.hcap_hp_sig ).toBe( 'hp-sig' ); + expect( formData.hcap_hp_test ).toBe( '' ); + } ); + + test( 'binds events again after Otter fetch complete', () => { + window.dispatchEvent( + new CustomEvent( 'hCaptchaFetch:complete', { + detail: { + args: [ + '/otter/v1/form/frontend', + ], + }, + } ), + ); + + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-passster.test.js b/tests/js/assets-js-files/hcaptcha-passster.test.js new file mode 100644 index 000000000..618d96599 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-passster.test.js @@ -0,0 +1,95 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Passster', () => { + let helperMock; + let ajaxPrefilterCallback; + let originalAjaxPrefilter; + + function loadPassster() { + jest.resetModules(); + helperMock = { + addHCaptchaData: jest.fn(), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + window.wp = { + hooks: { + addFilter: jest.fn(), + }, + }; + global.wp = window.wp; + originalAjaxPrefilter = $.ajaxPrefilter; + $.ajaxPrefilter = jest.fn( ( callback ) => { + ajaxPrefilterCallback = callback; + } ); + + require( '../../../assets/js/hcaptcha-passster.js' ); + } + + beforeEach( () => { + document.body.innerHTML = ` +
+ `; + loadPassster(); + } ); + + afterEach( () => { + $.ajaxPrefilter = originalAjaxPrefilter; + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.wp; + delete global.wp; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'marks Passster submit buttons as ajax submit buttons', () => { + const callback = window.wp.hooks.addFilter.mock.calls[ 0 ][ 2 ]; + const submitButton = document.createElement( 'button' ); + const otherButton = document.createElement( 'button' ); + + submitButton.classList.add( 'passster-submit' ); + + expect( callback( false, submitButton ) ).toBe( true ); + expect( callback( false, otherButton ) ).toBe( false ); + expect( callback( true, otherButton ) ).toBe( true ); + } ); + + test( 'ignores non-string ajax data and delegates empty query data to helper', () => { + const emptyOptions = {}; + + ajaxPrefilterCallback( emptyOptions ); + ajaxPrefilterCallback( { data: { area: 'secret' } } ); + + expect( helperMock.addHCaptchaData ).toHaveBeenCalledTimes( 1 ); + expect( helperMock.addHCaptchaData ).toHaveBeenCalledWith( + emptyOptions, + 'validate_input', + 'hcaptcha_passster_nonce', + expect.any( Object ), + ); + } ); + + test( 'adds hCaptcha data for the Passster area form', () => { + const options = { + data: 'action=validate_input&area=secret', + }; + + ajaxPrefilterCallback( options ); + + expect( helperMock.addHCaptchaData ).toHaveBeenCalledWith( + options, + 'validate_input', + 'hcaptcha_passster_nonce', + expect.objectContaining( { + 0: document.getElementById( 'area-form' ), + length: 1, + } ), + ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-quform.test.js b/tests/js/assets-js-files/hcaptcha-quform.test.js new file mode 100644 index 000000000..9d552b161 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-quform.test.js @@ -0,0 +1,31 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Quform frontend', () => { + beforeEach( () => { + jest.resetModules(); + window.hCaptchaBindEvents = jest.fn(); + global.hCaptchaBindEvents = window.hCaptchaBindEvents; + + require( '../../../assets/js/hcaptcha-quform.js' ); + } ); + + afterEach( () => { + $( document ).off( 'ajaxSuccess' ); + delete window.hCaptchaBindEvents; + delete global.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'rebinds hCaptcha after Quform submit ajax success only', () => { + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'quform_submit=preview' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'quform_submit=submit' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-sendinblue.test.js b/tests/js/assets-js-files/hcaptcha-sendinblue.test.js new file mode 100644 index 000000000..9ec6d4d6c --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-sendinblue.test.js @@ -0,0 +1,67 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Sendinblue', () => { + let helperMock; + + function loadSendinblue() { + jest.resetModules(); + helperMock = { + checkAction: jest.fn(), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + window.hCaptchaBindEvents = jest.fn(); + delete window.hCaptchaSendinblue; + + require( '../../../assets/js/hcaptcha-sendinblue.js' ); + } + + beforeEach( () => { + loadSendinblue(); + } ); + + afterEach( () => { + $( document ).off( 'ajaxComplete' ); + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.hCaptchaSendinblue; + delete window.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'rebinds hCaptcha after matching Sendinblue ajax complete only', () => { + const settings = { data: 'sib_form_action=subscribe_form_submit' }; + + helperMock.checkAction.mockReturnValueOnce( false ).mockReturnValueOnce( true ); + + $( document ).trigger( 'ajaxComplete', [ {}, settings ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'ajaxComplete', [ {}, settings ] ); + expect( helperMock.checkAction ).toHaveBeenCalledWith( + settings, + 'sib_form_action', + 'subscribe_form_submit', + ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaSendinblue = existingApp; + + require( '../../../assets/js/hcaptcha-sendinblue.js' ); + + expect( window.hCaptchaSendinblue ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-simple-download-monitor.test.js b/tests/js/assets-js-files/hcaptcha-simple-download-monitor.test.js new file mode 100644 index 000000000..ef49aa8cd --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-simple-download-monitor.test.js @@ -0,0 +1,38 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Simple Download Monitor', () => { + beforeEach( () => { + jest.resetModules(); + window.history.pushState( {}, '', '/' ); + document.body.innerHTML = ` +
+ + + Download +
+ `; + + require( '../../../assets/js/hcaptcha-simple-download-monitor.js' ); + } ); + + afterEach( () => { + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'adds hCaptcha fields to the download URL and prevents default navigation', () => { + const event = $.Event( 'click' ); + + $( 'a.sdm_download' ).trigger( event ); + + expect( event.isDefaultPrevented() ).toBe( true ); + expect( window.location.href ).toContain( '#download' ); + expect( window.location.href ).toContain( 'hcaptcha_simple_download_monitor_nonce=nonce-value' ); + expect( window.location.href ).toContain( 'h-captcha-response=response-token' ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-spectra.test.js b/tests/js/assets-js-files/hcaptcha-spectra.test.js new file mode 100644 index 000000000..b6f5ae7a0 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-spectra.test.js @@ -0,0 +1,236 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +describe( 'hCaptcha Spectra', () => { + let helperMock; + + function loadSpectra() { + jest.resetModules(); + helperMock = { + installFetchEvents: jest.fn(), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + window.hCaptchaBindEvents = jest.fn(); + delete window.hCaptchaSpectra; + + require( '../../../assets/js/hcaptcha-spectra.js' ); + } + + function eventWithBody( eventName, body, response = undefined ) { + return new CustomEvent( eventName, { + detail: { + args: [ '/spectra', { body } ], + response, + }, + } ); + } + + beforeEach( () => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + loadSpectra(); + } ); + + afterEach( () => { + if ( window.hCaptchaSpectra ) { + window.removeEventListener( 'hCaptchaFetch:before', window.hCaptchaSpectra.fetchBefore ); + window.removeEventListener( 'hCaptchaFetch:success', window.hCaptchaSpectra.fetchSuccess ); + window.removeEventListener( 'hCaptchaFetch:complete', window.hCaptchaSpectra.fetchComplete ); + } + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.hCaptchaSpectra; + delete window.hCaptchaBindEvents; + document.head.innerHTML = ''; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'installs fetch events and adds hCaptcha fields before Spectra fetch', () => { + document.body.innerHTML = ` +
+
Old error
+ + + + + + +
+ `; + const body = new URLSearchParams(); + + body.set( 'block_id', 'abc' ); + body.set( 'action', 'uagb_process_forms' ); + body.set( 'form_data', JSON.stringify( { email: 'person@test.test' } ) ); + + window.hCaptchaSpectra.fetchBefore( eventWithBody( 'hCaptchaFetch:before', body ) ); + + const formData = JSON.parse( body.get( 'form_data' ) ); + + expect( helperMock.installFetchEvents ).toHaveBeenCalledTimes( 1 ); + expect( document.querySelector( '.hcaptcha-error-message' ) ).toBeNull(); + expect( formData[ 'hcaptcha-widget-id' ] ).toBe( 'widget-id' ); + expect( formData[ 'h-captcha-response' ] ).toBe( 'response-token' ); + expect( formData.hcaptcha_spectra_form_nonce ).toBe( 'nonce-value' ); + expect( formData.hcap_hp_test ).toBe( 'hp-value' ); + expect( formData.hcap_hp_sig ).toBe( 'hp-sig' ); + expect( formData.hcap_fst_token ).toBe( 'fst-token' ); + } ); + + test( 'uses undefined fallbacks for missing Spectra hCaptcha fields', () => { + document.body.innerHTML = '
'; + const body = new URLSearchParams(); + + body.set( 'block_id', 'empty' ); + body.set( 'action', 'uagb_process_forms' ); + body.set( 'form_data', JSON.stringify( {} ) ); + + window.hCaptchaSpectra.fetchBefore( eventWithBody( 'hCaptchaFetch:before', body ) ); + + const formData = JSON.parse( body.get( 'form_data' ) ); + + expect( formData[ 'hcaptcha-widget-id' ] ).toBeUndefined(); + expect( formData[ 'h-captcha-response' ] ).toBeUndefined(); + expect( formData.hcaptcha_spectra_form_nonce ).toBeUndefined(); + expect( formData.undefined ).toBeUndefined(); + } ); + + test( 'ignores Spectra fetchBefore requests that do not need mutation', () => { + window.hCaptchaSpectra.fetchBefore(); + window.hCaptchaSpectra.fetchBefore( eventWithBody( 'hCaptchaFetch:before', new FormData() ) ); + + document.body.innerHTML = '
'; + const otherAction = new URLSearchParams(); + otherAction.set( 'block_id', 'abc' ); + otherAction.set( 'action', 'other_action' ); + otherAction.set( 'form_data', JSON.stringify( {} ) ); + window.hCaptchaSpectra.fetchBefore( eventWithBody( 'hCaptchaFetch:before', otherAction ) ); + + const existingResponse = new URLSearchParams(); + existingResponse.set( 'block_id', 'abc' ); + existingResponse.set( 'action', 'uagb_process_forms' ); + existingResponse.set( 'form_data', JSON.stringify( { 'h-captcha-response': 'already-there' } ) ); + window.hCaptchaSpectra.fetchBefore( eventWithBody( 'hCaptchaFetch:before', existingResponse ) ); + + expect( JSON.parse( existingResponse.get( 'form_data' ) )[ 'h-captcha-response' ] ).toBe( 'already-there' ); + } ); + + test( 'shows Spectra hCaptcha error messages on successful fetch response', async () => { + document.body.innerHTML = ` +
+
Previous error
+
+
+ `; + const body = new URLSearchParams(); + const response = { + clone: jest.fn( () => ( { + json: jest.fn( () => Promise.resolve( { data: 'Captcha failed.' } ) ), + } ) ), + }; + + body.set( 'action', 'uagb_process_forms' ); + body.set( 'block_id', 'abc' ); + + await window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', body, response ) ); + await window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', body, response ) ); + + expect( document.getElementById( 'hcaptcha-style-abc' ) ).not.toBeNull(); + expect( document.querySelectorAll( '.hcaptcha-error-message' ) ).toHaveLength( 1 ); + expect( document.querySelector( '.hcaptcha-error-message' ).textContent ).toBe( 'Captcha failed.' ); + } ); + + test( 'ignores Spectra fetchSuccess responses without hCaptcha error data', async () => { + const body = new URLSearchParams(); + body.set( 'action', 'uagb_process_forms' ); + body.set( 'block_id', 'abc' ); + + const badJsonResponse = { + clone: jest.fn( () => ( { + json: jest.fn( () => Promise.reject( new Error( 'bad json' ) ) ), + } ) ), + }; + const objectDataResponse = { + clone: jest.fn( () => ( { + json: jest.fn( () => Promise.resolve( { data: { message: 'No string.' } } ) ), + } ) ), + }; + const ignoredActionResponse = { + clone: jest.fn( () => ( { + json: jest.fn( () => Promise.resolve( { data: 'Ignored.' } ) ), + } ) ), + }; + + await expect( window.hCaptchaSpectra.fetchSuccess() ).resolves.toBeUndefined(); + await expect( window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', body ) ) ).resolves.toBeUndefined(); + await expect( window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', 'not-form-data', { clone: jest.fn() } ) ) ).resolves.toBeUndefined(); + await expect( window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', body, badJsonResponse ) ) ).resolves.toBeUndefined(); + await expect( window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', body, objectDataResponse ) ) ).resolves.toBeUndefined(); + + body.set( 'action', 'other_action' ); + await expect( window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', body, ignoredActionResponse ) ) ).resolves.toBeUndefined(); + } ); + + test( 'handles Spectra error response when no hCaptcha container is present', async () => { + document.body.innerHTML = '
'; + const body = new URLSearchParams(); + const response = { + clone: jest.fn( () => ( { + json: jest.fn( () => Promise.resolve( { data: 'Captcha failed.' } ) ), + } ) ), + }; + + body.set( 'action', 'uagb_process_forms' ); + body.set( 'block_id', 'abc' ); + + await window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', body, response ) ); + + expect( document.querySelector( '.hcaptcha-error-message' ) ).toBeNull(); + expect( document.getElementById( 'hcaptcha-style-abc' ) ).not.toBeNull(); + } ); + test( 'removes Spectra style and rebinds after fetch complete only', async () => { + const body = new URLSearchParams(); + const response = { + clone: jest.fn( () => ( { + json: jest.fn( () => Promise.resolve( { data: 'Captcha failed.' } ) ), + } ) ), + }; + + document.body.innerHTML = '
'; + body.set( 'action', 'uagb_process_forms' ); + body.set( 'block_id', 'abc' ); + await window.hCaptchaSpectra.fetchSuccess( eventWithBody( 'hCaptchaFetch:success', body, response ) ); + + window.hCaptchaSpectra.fetchComplete(); + window.hCaptchaSpectra.fetchComplete( eventWithBody( 'hCaptchaFetch:complete', new FormData() ) ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + window.hCaptchaSpectra.fetchComplete( eventWithBody( 'hCaptchaFetch:complete', body ) ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + window.hCaptchaBindEvents.mockClear(); + + const otherAction = new URLSearchParams(); + otherAction.set( 'action', 'other_action' ); + window.hCaptchaSpectra.fetchComplete( eventWithBody( 'hCaptchaFetch:complete', otherAction ) ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + window.hCaptchaSpectra.fetchComplete( eventWithBody( 'hCaptchaFetch:complete', body ) ); + expect( document.getElementById( 'hcaptcha-style-abc' ) ).toBeNull(); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaSpectra = existingApp; + + require( '../../../assets/js/hcaptcha-spectra.js' ); + + expect( window.hCaptchaSpectra ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-ultimate-addons.test.js b/tests/js/assets-js-files/hcaptcha-ultimate-addons.test.js new file mode 100644 index 000000000..33694b2c6 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-ultimate-addons.test.js @@ -0,0 +1,139 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha Ultimate Addons', () => { + let helperMock; + let ajaxPrefilterCallback; + let originalAjaxPrefilter; + + function loadUltimateAddons() { + jest.resetModules(); + helperMock = { + addHCaptchaData: jest.fn(), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + window.wp = { + hooks: { + addFilter: jest.fn(), + }, + }; + global.wp = window.wp; + window.hCaptchaBindEvents = jest.fn(); + originalAjaxPrefilter = $.ajaxPrefilter; + $.ajaxPrefilter = jest.fn( ( callback ) => { + ajaxPrefilterCallback = callback; + } ); + document.body.innerHTML = ` + +
+ `; + + require( '../../../assets/js/hcaptcha-ultimate-addons.js' ); + } + + beforeEach( () => { + loadUltimateAddons(); + } ); + + afterEach( () => { + $( document ).off( 'ajaxSuccess' ); + $.ajaxPrefilter = originalAjaxPrefilter; + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.wp; + delete global.wp; + delete window.hCaptchaBindEvents; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'marks Ultimate Addons login and register buttons as ajax submit buttons', () => { + const callback = window.wp.hooks.addFilter.mock.calls[ 0 ][ 2 ]; + const loginButton = document.createElement( 'button' ); + const registerButton = document.createElement( 'button' ); + const otherButton = document.createElement( 'button' ); + + loginButton.classList.add( 'uael-login-form-submit' ); + registerButton.classList.add( 'uael-register-submit' ); + + expect( callback( false, loginButton ) ).toBe( true ); + expect( callback( false, registerButton ) ).toBe( true ); + expect( callback( false, otherButton ) ).toBe( false ); + expect( callback( true, otherButton ) ).toBe( true ); + } ); + + test( 'ignores ajax prefilter data that cannot resolve a UAEL action', () => { + ajaxPrefilterCallback( {} ); + ajaxPrefilterCallback( { data: { action: 'uael_login_form_submit' } } ); + ajaxPrefilterCallback( { data: 'action=' } ); + ajaxPrefilterCallback( { data: 'action=other_action' } ); + + expect( helperMock.addHCaptchaData ).not.toHaveBeenCalled(); + } ); + + test( 'adds hCaptcha data for login and register actions', () => { + const loginOptions = { data: 'action=uael_login_form_submit' }; + const registerOptions = { data: 'action=uael_register_user' }; + + ajaxPrefilterCallback( loginOptions ); + ajaxPrefilterCallback( registerOptions ); + + expect( helperMock.addHCaptchaData ).toHaveBeenNthCalledWith( + 1, + loginOptions, + 'uael_login_form_submit', + 'hcaptcha_login_nonce', + expect.objectContaining( { length: 1 } ), + ); + expect( helperMock.addHCaptchaData ).toHaveBeenNthCalledWith( + 2, + loginOptions, + 'uael_register_user', + 'hcaptcha_ultimate_addons_register_nonce', + expect.objectContaining( { length: 1 } ), + ); + expect( helperMock.addHCaptchaData ).toHaveBeenNthCalledWith( + 3, + registerOptions, + 'uael_login_form_submit', + 'hcaptcha_login_nonce', + expect.objectContaining( { length: 1 } ), + ); + expect( helperMock.addHCaptchaData ).toHaveBeenNthCalledWith( + 4, + registerOptions, + 'uael_register_user', + 'hcaptcha_ultimate_addons_register_nonce', + expect.objectContaining( { length: 1 } ), + ); + } ); + + test( 'rebinds hCaptcha and displays hCaptcha error for failed UAEL ajax responses', () => { + $( document ).trigger( 'ajaxSuccess', [ { responseText: JSON.stringify( { success: true } ) }, { data: 'action=uael_login_form_submit' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + expect( document.querySelector( '.uael-register-field-message' ) ).toBeNull(); + + $( document ).trigger( 'ajaxSuccess', [ { responseText: JSON.stringify( { success: false, data: '' } ) }, { data: 'action=uael_register_user' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 2 ); + expect( document.querySelector( '.uael-register-field-message' ) ).toBeNull(); + + $( document ).trigger( 'ajaxSuccess', [ { responseText: 'null' }, { data: 'action=uael_register_user' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 3 ); + expect( document.querySelector( '.uael-register-field-message' ) ).toBeNull(); + + $( document ).trigger( 'ajaxSuccess', [ { responseText: JSON.stringify( { success: false, data: { hCaptchaError: 'Captcha failed.' } } ) }, { data: 'action=uael_register_user' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 4 ); + expect( document.querySelector( '.uael-loginform-error' ).textContent ).toBe( 'Captcha failed.' ); + } ); + + test( 'ignores unrelated UAEL ajax success actions', () => { + $( document ).trigger( 'ajaxSuccess', [ { responseText: '{}' }, { data: 'action=other_action' } ] ); + + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-users-wp.test.js b/tests/js/assets-js-files/hcaptcha-users-wp.test.js new file mode 100644 index 000000000..a1b02f702 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-users-wp.test.js @@ -0,0 +1,37 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha UsersWP', () => { + beforeEach( () => { + jest.resetModules(); + window.hCaptchaBindEvents = jest.fn(); + + require( '../../../assets/js/hcaptcha-users-wp.js' ); + } ); + + afterEach( () => { + $( document ).off( 'ajaxSuccess' ); + delete window.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test.each( [ + 'uwp_ajax_forgot_password_form', + 'uwp_ajax_login_form', + 'uwp_ajax_register_form', + ] )( 'rebinds hCaptcha after UsersWP %s ajax success', ( action ) => { + $( document ).trigger( 'ajaxSuccess', [ {}, { data: `action=${ action }` } ] ); + + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'ignores unrelated UsersWP ajax actions', () => { + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'action=other_action' } ] ); + + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-wc-block-checkout.test.js b/tests/js/assets-js-files/hcaptcha-wc-block-checkout.test.js new file mode 100644 index 000000000..4315b07bd --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-wc-block-checkout.test.js @@ -0,0 +1,134 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +describe( 'hCaptcha WooCommerce block checkout', () => { + let helperMock; + + function loadCheckout() { + jest.resetModules(); + helperMock = { + installFetchEvents: jest.fn(), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + window.wp = { + hooks: { + addFilter: jest.fn(), + }, + }; + global.wp = window.wp; + window.hCaptchaBindEvents = jest.fn(); + delete window.hCaptchaWCBlockCheckout; + + require( '../../../assets/js/hcaptcha-wc-block-checkout.js' ); + } + + function eventWithArgs( eventName, args ) { + return new CustomEvent( eventName, { + detail: { args }, + } ); + } + + beforeEach( () => { + document.body.innerHTML = ''; + loadCheckout(); + } ); + + afterEach( () => { + if ( window.hCaptchaWCBlockCheckout ) { + window.removeEventListener( 'hCaptchaFetch:before', window.hCaptchaWCBlockCheckout.fetchBefore ); + window.removeEventListener( 'hCaptchaFetch:complete', window.hCaptchaWCBlockCheckout.fetchComplete ); + } + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + delete window.hCaptchaWCBlockCheckout; + delete window.hCaptchaBindEvents; + delete window.wp; + delete global.wp; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'registers checkout button selectors and installs fetch events', () => { + const selectorFilter = window.wp.hooks.addFilter.mock.calls[ 0 ][ 2 ]; + const ajaxFilter = window.wp.hooks.addFilter.mock.calls[ 1 ][ 2 ]; + const checkoutButton = document.createElement( 'button' ); + const otherButton = document.createElement( 'button' ); + + checkoutButton.classList.add( 'wc-block-components-checkout-place-order-button' ); + + expect( selectorFilter( 'button[type="submit"]' ) ).toBe( 'button[type="submit"], .wc-block-components-checkout-place-order-button' ); + expect( ajaxFilter( false, checkoutButton ) ).toBe( true ); + expect( ajaxFilter( false, otherButton ) ).toBe( false ); + expect( ajaxFilter( true, otherButton ) ).toBe( true ); + expect( helperMock.installFetchEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'adds hCaptcha fields to WooCommerce checkout block fetch body', () => { + document.body.innerHTML = ` +
+ + + + + +
+ `; + const config = { body: JSON.stringify( { existing: 'value' } ) }; + const event = eventWithArgs( 'hCaptchaFetch:before', [ '/wc/store/v1/checkout', config ] ); + + window.hCaptchaWCBlockCheckout.fetchBefore( event ); + + const body = JSON.parse( config.body ); + + expect( body.existing ).toBe( 'value' ); + expect( body[ 'hcaptcha-widget-id' ] ).toBe( 'widget-id' ); + expect( body[ 'h-captcha-response' ] ).toBe( 'response-token' ); + expect( body.hcap_hp_test ).toBe( 'hp-value' ); + expect( body.hcap_hp_sig ).toBe( 'hp-sig' ); + expect( body.hcap_fst_token ).toBe( 'fst-token' ); + expect( event.detail.args.config ).toBe( config ); + } ); + + test( 'uses empty fallback values when checkout hCaptcha inputs are missing', () => { + document.body.innerHTML = '
'; + const config = { body: '{' }; + + window.hCaptchaWCBlockCheckout.fetchBefore( eventWithArgs( 'hCaptchaFetch:before', [ '/wc/store/v1/checkout', config ] ) ); + + const body = JSON.parse( config.body ); + + expect( body[ 'hcaptcha-widget-id' ] ).toBe( '' ); + expect( body[ 'h-captcha-response' ] ).toBe( '' ); + expect( body[ '' ] ).toBe( '' ); + expect( body.hcap_hp_sig ).toBe( '' ); + expect( body.hcap_fst_token ).toBe( '' ); + } ); + + test( 'ignores non-checkout fetches and rebinds after checkout fetch complete only', () => { + const config = { body: '{}' }; + + window.hCaptchaWCBlockCheckout.fetchBefore( eventWithArgs( 'hCaptchaFetch:before', [ '/other-route', config ] ) ); + expect( config.body ).toBe( '{}' ); + + window.hCaptchaWCBlockCheckout.fetchComplete(); + window.hCaptchaWCBlockCheckout.fetchComplete( eventWithArgs( 'hCaptchaFetch:complete', [ '/other-route' ] ) ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + window.hCaptchaWCBlockCheckout.fetchComplete( eventWithArgs( 'hCaptchaFetch:complete', [ '/wc/store/v1/checkout' ] ) ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaWCBlockCheckout = existingApp; + + require( '../../../assets/js/hcaptcha-wc-block-checkout.js' ); + + expect( window.hCaptchaWCBlockCheckout ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-woocommerce-paypal-payments-early.test.js b/tests/js/assets-js-files/hcaptcha-woocommerce-paypal-payments-early.test.js new file mode 100644 index 000000000..0d86cc757 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-woocommerce-paypal-payments-early.test.js @@ -0,0 +1,182 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +describe( 'hCaptcha WooCommerce PayPal Payments early wrapper', () => { + beforeEach( () => { + jest.resetModules(); + jest.useFakeTimers(); + delete window.__hCaptchaWooCommercePayPalPaymentsEarlyWrapped; + delete window.hCaptchaWooCommercePayPalPayments; + delete window.paypal; + delete window.ppcpBlocksPaypalExpressButtons; + delete window.widgetBuilder; + } ); + + afterEach( () => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + delete window.__hCaptchaWooCommercePayPalPaymentsEarlyWrapped; + delete window.hCaptchaWooCommercePayPalPayments; + delete window.paypal; + delete window.ppcpBlocksPaypalExpressButtons; + delete window.widgetBuilder; + jest.restoreAllMocks(); + } ); + + test( 'wraps existing PayPal Buttons and widgetBuilder options', async () => { + const originalButtons = jest.fn( ( options ) => options ); + const originalRegisterButtons = jest.fn( ( wrapper, options ) => options ); + const originalClick = jest.fn( () => 'clicked' ); + const actions = { + resolve: jest.fn( () => 'resolved' ), + }; + + window.hCaptchaWooCommercePayPalPayments = { + executeCaptchaBeforePayPal: jest.fn( () => Promise.resolve() ), + }; + window.paypal = { + Buttons: originalButtons, + }; + window.widgetBuilder = { + registerButtons: originalRegisterButtons, + buttons: new Map( [ + [ 'button', { wrapper: '#wrapper', options: { onClick: originalClick } } ], + ] ), + }; + + require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments-early.js' ); + + const buttonOptions = window.paypal.Buttons( { onClick: originalClick } ); + const registeredOptions = window.widgetBuilder.registerButtons( '#wrapper', { onClick: originalClick } ); + + expect( window.__hCaptchaWooCommercePayPalPaymentsEarlyWrapped ).toBe( true ); + expect( buttonOptions.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + expect( registeredOptions.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + expect( window.widgetBuilder.buttons.get( 'button' ).options.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + await expect( buttonOptions.onClick( 'data', actions ) ).resolves.toBe( 'clicked' ); + expect( window.hCaptchaWooCommercePayPalPayments.executeCaptchaBeforePayPal ).toHaveBeenCalledWith( '' ); + expect( originalClick ).toHaveBeenCalledWith( 'data', actions ); + } ); + + test( 'resolves PayPal onClick with actions.resolve when original click has no result', async () => { + const actions = { + resolve: jest.fn( () => 'resolved' ), + }; + + window.hCaptchaWooCommercePayPalPayments = { + executeCaptchaBeforePayPal: jest.fn( () => Promise.resolve() ), + }; + window.paypal = { + Buttons: jest.fn( ( options ) => options ), + }; + + require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments-early.js' ); + + const buttonOptions = window.paypal.Buttons( {} ); + + await expect( buttonOptions.onClick( 'data', actions ) ).resolves.toBe( 'resolved' ); + expect( actions.resolve ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'wraps PayPal and widgetBuilder objects assigned after early script loads', () => { + require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments-early.js' ); + + window.paypal = { + Buttons: jest.fn( ( options ) => options ), + }; + window.widgetBuilder = { + registerButtons: jest.fn( ( wrapper, options ) => options ), + }; + + expect( window.paypal.Buttons( {} ).__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + expect( window.widgetBuilder.registerButtons( '#wrapper', {} ).__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + } ); + + test( 'waits for the main PayPal app and gives up after retry limit', async () => { + window.paypal = { + Buttons: jest.fn( ( options ) => options ), + }; + + require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments-early.js' ); + + const withApp = window.paypal.Buttons( {} ); + const withAppPromise = withApp.onClick( 'data', {} ); + window.hCaptchaWooCommercePayPalPayments = { + executeCaptchaBeforePayPal: jest.fn( () => Promise.resolve( 'captcha' ) ), + }; + jest.advanceTimersByTime( 50 ); + await expect( withAppPromise ).resolves.toBeUndefined(); + + delete window.hCaptchaWooCommercePayPalPayments; + const withoutApp = window.paypal.Buttons( {} ); + const withoutAppPromise = withoutApp.onClick( 'data', {} ); + jest.advanceTimersByTime( 2100 ); + await expect( withoutAppPromise ).resolves.toBeUndefined(); + } ); + + test( 'returns early when already wrapped and tolerates defineProperty failures', () => { + window.__hCaptchaWooCommercePayPalPaymentsEarlyWrapped = true; + require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments-early.js' ); + expect( window.__hCaptchaWooCommercePayPalPaymentsEarlyWrapped ).toBe( true ); + + jest.resetModules(); + delete window.__hCaptchaWooCommercePayPalPaymentsEarlyWrapped; + window.paypal = { + Buttons: jest.fn(), + }; + window.widgetBuilder = { + buttons: [], + }; + jest.spyOn( Object, 'defineProperty' ).mockImplementation( () => { + throw new Error( 'define blocked' ); + } ); + + expect( () => require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments-early.js' ) ).not.toThrow(); + } ); + + test( 'falls back when PayPal Buttons property wrapping fails', () => { + const paypal = { + Buttons: jest.fn( ( options ) => options ), + }; + const originalDefineProperty = Object.defineProperty; + + window.paypal = paypal; + jest.spyOn( Object, 'defineProperty' ).mockImplementation( ( target, property, descriptor ) => { + if ( target === paypal && property === 'Buttons' ) { + throw new Error( 'buttons blocked' ); + } + + return originalDefineProperty( target, property, descriptor ); + } ); + + require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments-early.js' ); + + expect( window.paypal.Buttons.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + } ); + test( 'covers early wrapper guard branches for empty and pre-wrapped values', () => { + const wrappedButtons = jest.fn(); + wrappedButtons.__hCaptchaWooCommercePayPalPaymentsWrapped = true; + const watchedMarker = '__hCaptchaWooCommercePayPalPaymentsWrappedButtonsWatched'; + + window.paypal = { + Buttons: null, + }; + window.ppcpBlocksPaypalExpressButtons = { + Buttons: jest.fn(), + [ watchedMarker ]: true, + }; + window.widgetBuilder = { + buttons: new Map( [ + [ 'empty', { wrapper: '#wrapper', options: null } ], + ] ), + }; + + require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments-early.js' ); + + expect( window.paypal.Buttons ).toBeNull(); + expect( window.ppcpBlocksPaypalExpressButtons[ watchedMarker ] ).toBe( true ); + expect( window.widgetBuilder.buttons.get( 'empty' ).options ).toBeNull(); + + window.paypal.Buttons = wrappedButtons; + expect( window.paypal.Buttons ).toBe( wrappedButtons ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-woocommerce-paypal-payments.test.js b/tests/js/assets-js-files/hcaptcha-woocommerce-paypal-payments.test.js new file mode 100644 index 000000000..1019cc72a --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-woocommerce-paypal-payments.test.js @@ -0,0 +1,429 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha WooCommerce PayPal Payments', () => { + let helperMock; + let previousFetch; + let originalMutationObserver; + let observers; + + function setupGlobals( options = {} ) { + observers = []; + originalMutationObserver = window.MutationObserver; + window.MutationObserver = jest.fn( function( callback ) { + this.callback = callback; + this.observe = jest.fn(); + observers.push( this ); + } ); + global.MutationObserver = window.MutationObserver; + window.wp = { + hooks: { + addFilter: jest.fn(), + }, + }; + global.wp = window.wp; + window.jQuery = $; + previousFetch = jest.fn( () => Promise.resolve( 'fetch-response' ) ); + window.fetch = previousFetch; + window.hCaptchaBindEvents = jest.fn(); + window.hCaptcha = { + getWidgetId: jest.fn( () => 'widget-id' ), + }; + window.hcaptcha = { + execute: jest.fn( () => { + document.dispatchEvent( new CustomEvent( 'hCaptchaSubmitted' ) ); + } ), + }; + global.hcaptcha = window.hcaptcha; + window.widgetBuilder = options.widgetBuilder || { + registerButtons: jest.fn( ( wrapper, buttonOptions ) => buttonOptions ), + buttons: new Map( [ + [ 'button', { wrapper: '#paypal-wrapper', options: { label: 'paypal' } } ], + ] ), + }; + window.paypal = options.paypal || { + Buttons: jest.fn( ( buttonOptions ) => ( { buttonOptions } ) ), + }; + } + + function loadPayPal( options = {} ) { + jest.resetModules(); + helperMock = { + getHCaptchaData: jest.fn( ( root, nonceName ) => ( { + 'h-captcha-response': root?.dataset?.response || '', + 'hcaptcha-widget-id': root?.dataset?.widget || '', + [ nonceName ]: root?.dataset?.nonce || '', + hcap_hp_sig: root?.dataset?.sig || '', + hcap_fst_token: root?.dataset?.token || '', + } ) ), + }; + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: helperMock, + } ) ); + setupGlobals( options ); + delete window.hCaptchaWooCommercePayPalPayments; + delete window.__hCaptchaWooCommercePayPalPaymentsFetchWrapped; + document.body.innerHTML = options.html || ''; + + require( '../../../assets/js/hcaptcha-woocommerce-paypal-payments.js' ); + + return window.hCaptchaWooCommercePayPalPayments; + } + + beforeEach( () => { + jest.useFakeTimers(); + } ); + + afterEach( () => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + $( document ).off( 'ppcp-paypal-loaded' ); + window.MutationObserver = originalMutationObserver; + global.MutationObserver = originalMutationObserver; + delete window.hCaptchaWooCommercePayPalPayments; + delete window.__hCaptchaWooCommercePayPalPaymentsFetchWrapped; + delete window.widgetBuilder; + delete window.paypal; + delete window.ppcpBlocksPaypalExpressButtons; + delete window[ 'ppcp-blocks-editor-paypal-buttons' ]; + delete window.wp; + delete global.wp; + delete window.hCaptcha; + delete window.hcaptcha; + delete global.hcaptcha; + delete window.hCaptchaBindEvents; + document.body.innerHTML = ''; + document.head.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'initializes filters and wraps widgetBuilder and PayPal Buttons', async () => { + const app = loadPayPal(); + const selectorFilter = window.wp.hooks.addFilter.mock.calls[ 0 ][ 2 ]; + const registered = window.widgetBuilder.registerButtons( '#paypal-wrapper', { onClick: jest.fn() } ); + const buttonResult = window.paypal.Buttons( { onClick: jest.fn() } ); + + expect( selectorFilter( 'form' ) ).toBe( 'form, .hcaptcha-woocommerce-paypal-payments, .widget_shopping_cart' ); + expect( window.widgetBuilder.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + expect( registered.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + expect( window.widgetBuilder.buttons.get( 'button' ).options.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + expect( buttonResult.buttonOptions.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + expect( app.getFormSelector( 'form' ) ).toContain( '.widget_shopping_cart' ); + } ); + + test( 'wraps widgetBuilder when stored buttons are not a Map', () => { + loadPayPal( { + widgetBuilder: { + registerButtons: jest.fn( ( wrapper, buttonOptions ) => buttonOptions ), + buttons: {}, + }, + } ); + + const registered = window.widgetBuilder.registerButtons( '#paypal-wrapper', { onClick: jest.fn() } ); + + expect( window.widgetBuilder.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + expect( registered.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + } ); + test( 'wrapper scheduling and namespace guards are idempotent', () => { + const app = loadPayPal(); + + app.installPayPalButtonsWrapper(); + delete window.widgetBuilder; + app.widgetBuilderTimer = null; + app.widgetBuilderAttempts = 0; + app.installPayPalButtonsWrapper(); + expect( app.widgetBuilderTimer ).not.toBeNull(); + app.schedulePayPalButtonsWrapper(); + app.widgetBuilderAttempts = 41; + app.widgetBuilderTimer = null; + expect( () => app.schedulePayPalButtonsWrapper() ).not.toThrow(); + + delete window.paypal; + delete window.ppcpBlocksPaypalExpressButtons; + delete window[ 'ppcp-blocks-editor-paypal-buttons' ]; + app.payPalSdkTimer = null; + app.payPalSdkAttempts = 0; + app.installPayPalSdkWrapper(); + expect( app.payPalSdkTimer ).not.toBeNull(); + app.schedulePayPalSdkWrapper(); + app.payPalSdkAttempts = 41; + app.payPalSdkTimer = null; + expect( () => app.schedulePayPalSdkWrapper() ).not.toThrow(); + + expect( app.wrapPayPalNamespace( 'missingNamespace' ) ).toBe( false ); + window.jQuery = undefined; + expect( () => app.listenForPayPalSdk() ).not.toThrow(); + expect( () => app.wrapLoadedPayPalNamespace() ).not.toThrow(); + } ); + + test( 'watches PayPal namespaces through loaded event and defineProperty fallback', () => { + const app = loadPayPal(); + const loadedPaypal = { + Buttons: jest.fn( ( options ) => options ), + }; + + app.wrapLoadedPayPalNamespace( loadedPaypal ); + expect( loadedPaypal.__hCaptchaWooCommercePayPalPaymentsButtonsWatched ).toBe( true ); + app.watchPayPalButtons( loadedPaypal ); + + const paypal = { + Buttons: jest.fn( ( options ) => options ), + }; + const originalDefineProperty = Object.defineProperty; + jest.spyOn( Object, 'defineProperty' ).mockImplementationOnce( () => { + throw new Error( 'blocked' ); + } ); + + app.watchPayPalButtons( paypal ); + + expect( paypal.Buttons.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + Object.defineProperty = originalDefineProperty; + } ); + + test( 'wrap option helpers handle empty and already wrapped options', () => { + const app = loadPayPal(); + const wrappedOptions = { + __hCaptchaWooCommercePayPalPaymentsWrapped: true, + }; + const wrappedButtons = function() {}; + wrappedButtons.__hCaptchaWooCommercePayPalPaymentsWrapped = true; + + expect( app.wrapPayPalButtonOptions( '', null ) ).toBeNull(); + expect( app.wrapPayPalButtonOptions( '', wrappedOptions ) ).toBe( wrappedOptions ); + expect( app.wrapPayPalButtons( {}, null ) ).toBeNull(); + expect( app.wrapPayPalButtons( {}, wrappedButtons ) ).toBe( wrappedButtons ); + } ); + + test( 'wrapped PayPal onClick executes captcha, original handler, and fallback actions', async () => { + const app = loadPayPal(); + const originalOnClick = jest.fn( function() { + return 'original-result'; + } ); + const actions = { + resolve: jest.fn( () => 'resolved' ), + }; + const wrappedWithResult = app.wrapPayPalButtonOptions( '', { onClick: originalOnClick } ); + const wrappedWithoutResult = app.wrapPayPalButtonOptions( '', {} ); + + jest.spyOn( app, 'executeCaptchaBeforePayPal' ).mockResolvedValue( undefined ); + + await expect( wrappedWithResult.onClick( 'data', actions ) ).resolves.toBe( 'original-result' ); + await expect( wrappedWithoutResult.onClick( 'data', actions ) ).resolves.toBe( 'resolved' ); + + expect( originalOnClick ).toHaveBeenCalledWith( 'data', actions ); + expect( actions.resolve ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'fetch wrapper handles non PayPal and PayPal create-order requests', async () => { + const app = loadPayPal( { + html: '
', + } ); + + app.executeCheckoutCaptcha = jest.fn( () => Promise.resolve() ); + app.executePayPalCaptcha = jest.fn( () => Promise.resolve() ); + app.moveBlockCaptcha = jest.fn(); + + await window.fetch( '/plain-endpoint', { body: '{}' } ); + expect( previousFetch ).toHaveBeenCalledWith( '/plain-endpoint', { body: '{}' } ); + + app.prepareCreateOrderConfig = jest.fn() + .mockReturnValueOnce( { body: JSON.stringify( { context: 'checkout' } ) } ) + .mockReturnValueOnce( { body: JSON.stringify( { context: 'checkout', 'h-captcha-response': 'after-checkout' } ) } ); + await window.fetch( '/?wc-ajax=ppc-create-order', { body: JSON.stringify( { context: 'checkout' } ) } ); + expect( app.executeCheckoutCaptcha ).toHaveBeenCalledTimes( 1 ); + expect( JSON.parse( previousFetch.mock.calls.at( -1 )[ 1 ].body )[ 'h-captcha-response' ] ).toBe( 'after-checkout' ); + + app.prepareCreateOrderConfig = jest.fn() + .mockReturnValueOnce( { body: JSON.stringify( { context: 'cart' } ) } ) + .mockReturnValueOnce( { body: JSON.stringify( { context: 'cart', 'h-captcha-response': 'after-paypal' } ) } ); + await window.fetch( '/?wc-ajax=ppc-create-order', { body: JSON.stringify( { context: 'cart' } ) } ); + expect( app.executePayPalCaptcha ).toHaveBeenCalledWith( '' ); + + app.prepareCreateOrderConfig = jest.fn( ( config ) => ( { ...config, body: JSON.stringify( { context: 'cart', 'h-captcha-response': 'present' } ) } ) ); + await window.fetch( '/?wc-ajax=ppc-create-order' ); + expect( app.executePayPalCaptcha ).toHaveBeenCalledTimes( 1 ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalled(); + expect( app.moveBlockCaptcha ).toHaveBeenCalled(); + + const moveBlockCaptchaCalls = app.moveBlockCaptcha.mock.calls.length; + document.body.innerHTML = ''; + await window.fetch( '/?wc-ajax=ppc-create-order' ); + expect( app.moveBlockCaptcha ).toHaveBeenCalledTimes( moveBlockCaptchaCalls ); + + window.__hCaptchaWooCommercePayPalPaymentsFetchWrapped = true; + expect( () => app.installFetchWrapper() ).not.toThrow(); + } ); + + test( 'stores checkout captcha data only when response exists', () => { + const app = loadPayPal( { + html: '
', + } ); + + app.storeCheckoutCaptchaData(); + expect( app.checkoutCaptchaData[ 'h-captcha-response' ] ).toBe( 'stored-token' ); + + document.body.innerHTML = '
'; + app.checkoutCaptchaData = null; + app.storeCheckoutCaptchaData(); + expect( app.checkoutCaptchaData ).toBeNull(); + } ); + + test( 'URL, body, context, and config helpers cover fallback paths', () => { + const app = loadPayPal(); + + expect( app.getUrl( '/url' ) ).toBe( '/url' ); + expect( app.getUrl( { url: '/object-url' } ) ).toBe( '/object-url' ); + expect( app.getUrl( null ) ).toBe( '' ); + expect( app.getJsonBody( '{' ) ).toEqual( {} ); + expect( app.isCheckoutContext( 'checkout' ) ).toBe( true ); + expect( app.isCheckoutContext( 'checkout-block' ) ).toBe( true ); + expect( app.isCheckoutContext( 'cart' ) ).toBe( false ); + + jest.spyOn( app, 'getCaptchaData' ).mockReturnValue( { 'h-captcha-response': 'paypal-token' } ); + expect( JSON.parse( app.prepareCreateOrderConfig( { body: JSON.stringify( { context: 'cart' } ) } ).body )[ 'h-captcha-response' ] ).toBe( 'paypal-token' ); + } ); + + test( 'execute captcha path handles checkout, PayPal, and guard branches', async () => { + const app = loadPayPal( { + html: ` +
+
+ `, + } ); + + jest.spyOn( app, 'executeCheckoutCaptcha' ).mockResolvedValueOnce( undefined ); + jest.spyOn( app, 'executePayPalCaptcha' ).mockResolvedValueOnce( undefined ); + await app.executeCaptchaBeforePayPal( '#paypal-wrapper' ); + expect( app.executePayPalCaptcha ).toHaveBeenCalledWith( '#paypal-wrapper' ); + + await app.executeCaptchaBeforePayPal( '' ); + expect( app.executeCheckoutCaptcha ).toHaveBeenCalledTimes( 1 ); + + app.executeCheckoutCaptcha.mockRestore(); + await expect( app.executeCheckoutCaptcha() ).resolves.toBeUndefined(); + + document.body.innerHTML = '
'; + const waitPromise = app.executeCaptcha( document.getElementById( 'root' ) ); + expect( window.hcaptcha.execute ).toHaveBeenCalledWith( 'widget-id', { async: false } ); + await waitPromise; + + expect( app.executeCaptcha( null ) ).resolves.toBeUndefined(); + document.body.innerHTML = '
'; + await expect( app.executeCaptcha( document.getElementById( 'root' ) ) ).resolves.toBeUndefined(); + window.hCaptcha.getWidgetId.mockReturnValueOnce( '' ); + document.querySelector( '[name="h-captcha-response"]' ).value = ''; + await expect( app.executeCaptcha( document.getElementById( 'root' ) ) ).resolves.toBeUndefined(); + } ); + + test( 'wrapper, root, and captcha data helpers resolve all supported shapes', () => { + const app = loadPayPal( { + html: ` +
+
+
+
+
+
+ `, + } ); + + expect( app.getWrapperElement( [ '#inside-wrapper' ] ) ).toBe( document.getElementById( 'inside-wrapper' ) ); + expect( app.getWrapperElement( '' ) ).toBeNull(); + expect( app.getWrapperElement( document.getElementById( 'inside-wrapper' ) ) ).toBe( document.getElementById( 'inside-wrapper' ) ); + expect( app.getWrapperElement( 12 ) ).toBeNull(); + expect( app.isCheckoutButtonWrapper( '#inside-wrapper' ) ).toBe( false ); + expect( app.getClosestPayPalCaptchaRoot( '#inside-wrapper' ).dataset.response ).toBe( 'inside-token' ); + expect( app.getClosestPayPalCaptchaRoot( '' ).dataset.response ).toBe( 'inside-token' ); + expect( app.getCheckoutCaptchaRoot().id ).toBe( 'block-checkout' ); + expect( app.hasCheckoutCaptcha( document.getElementById( 'classic-checkout' ) ) ).toBe( true ); + expect( app.hasPayPalCaptcha() ).toBe( true ); + + app.checkoutCaptchaData = { 'h-captcha-response': 'stored-token' }; + expect( app.getCheckoutCaptchaData()[ 'h-captcha-response' ] ).toBe( 'block-token' ); + document.getElementById( 'block-checkout' ).dataset.response = ''; + expect( app.getCheckoutCaptchaData()[ 'h-captcha-response' ] ).toBe( 'stored-token' ); + + document.body.innerHTML = '
'; + helperMock.getHCaptchaData.mockImplementation( ( root ) => ( { + 'h-captcha-response': root?.dataset?.response || '', + shared: root?.id === 'paypal-form' ? 'form-shared' : '', + } ) ); + expect( app.getCaptchaData( 'nonce' ).shared ).toBe( 'form-shared' ); + } ); + + test( 'block captcha observation and movement cover guard branches', () => { + const app = loadPayPal( { + html: ` +
+
+
+ + `, + } ); + + expect( observers[ 0 ].observe ).toHaveBeenCalledWith( document.body, { childList: true, subtree: true } ); + app.observeBlockCaptcha(); + app.moveBlockCaptcha(); + expect( document.querySelector( '.wc-block-components-express-payment__event-buttons' ).nextElementSibling.classList.contains( 'hcaptcha-woocommerce-paypal-payments' ) ).toBe( true ); + expect( document.querySelector( '.hcaptcha-woocommerce-paypal-payments' ).style.display ).toBe( '' ); + expect( app.getBlockCaptcha( null ) ).toBeNull(); + } ); + test( 'covers PayPal SDK loaded handler and Buttons setter branches', () => { + const app = loadPayPal(); + const loadedPaypal = { + Buttons: jest.fn( ( options ) => options ), + }; + const nextButtons = jest.fn( ( options ) => options ); + const spy = jest.spyOn( app, 'wrapLoadedPayPalNamespace' ); + + $( document ).trigger( 'ppcp-paypal-loaded', [ loadedPaypal ] ); + expect( spy ).toHaveBeenCalledWith( loadedPaypal ); + expect( loadedPaypal.__hCaptchaWooCommercePayPalPaymentsButtonsWatched ).toBe( true ); + + loadedPaypal.Buttons = nextButtons; + expect( loadedPaypal.Buttons.__hCaptchaWooCommercePayPalPaymentsWrapped ).toBe( true ); + } ); + + test( 'covers remaining PayPal helper fallback branches', async () => { + const app = loadPayPal(); + const wrappedWithoutActions = app.wrapPayPalButtonOptions( '', {} ); + + jest.spyOn( app, 'executeCaptchaBeforePayPal' ).mockResolvedValue( undefined ); + await expect( wrappedWithoutActions.onClick( 'data', {} ) ).resolves.toBeUndefined(); + + app.executeCaptchaBeforePayPal.mockRestore(); + jest.spyOn( app, 'executePayPalCaptcha' ).mockResolvedValue( undefined ); + jest.spyOn( app, 'executeCheckoutCaptcha' ).mockResolvedValue( undefined ); + await app.executeCaptchaBeforePayPal( '' ); + expect( app.executePayPalCaptcha ).toHaveBeenCalledWith( '' ); + + app.executePayPalCaptcha.mockRestore(); + app.executeCheckoutCaptcha.mockRestore(); + app.executeCaptcha = jest.fn( () => Promise.resolve() ); + document.body.innerHTML = '
'; + await app.executeCheckoutCaptcha(); + expect( app.executeCaptcha ).toHaveBeenCalledWith( document.querySelector( 'form.checkout' ) ); + + await app.executePayPalCaptcha( '' ); + expect( app.executeCaptcha ).toHaveBeenCalledWith( document.querySelector( '.hcaptcha-woocommerce-paypal-payments' ) ); + + document.body.innerHTML = '
'; + expect( () => app.moveBlockCaptcha() ).not.toThrow(); + } ); + + test( 'real prepareCreateOrderConfig merges checkout captcha data', () => { + const app = loadPayPal( { + html: '
', + } ); + const config = app.prepareCreateOrderConfig( { + body: JSON.stringify( { context: 'checkout' } ), + } ); + + expect( JSON.parse( config.body )[ 'h-captcha-response' ] ).toBe( 'checkout-token' ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-wpdiscuz-comment.test.js b/tests/js/assets-js-files/hcaptcha-wpdiscuz-comment.test.js new file mode 100644 index 000000000..951a5d887 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-wpdiscuz-comment.test.js @@ -0,0 +1,105 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +global.jQuery = $; +global.$ = $; + +describe( 'hCaptcha wpDiscuz comment', () => { + let domReadyCallback; + let originalAddEventListener; + let originalMutationObserver; + let observers; + + function loadWpDiscuz( html = '
' ) { + jest.resetModules(); + document.body.innerHTML = html; + domReadyCallback = null; + observers = []; + originalAddEventListener = document.addEventListener.bind( document ); + jest.spyOn( document, 'addEventListener' ).mockImplementation( ( eventName, callback, options ) => { + if ( eventName === 'DOMContentLoaded' ) { + domReadyCallback = callback; + + return undefined; + } + + return originalAddEventListener( eventName, callback, options ); + } ); + originalMutationObserver = window.MutationObserver; + window.MutationObserver = jest.fn( function( callback ) { + this.callback = callback; + this.observe = jest.fn(); + observers.push( this ); + } ); + global.MutationObserver = window.MutationObserver; + window.wp = { + hooks: { + addFilter: jest.fn(), + }, + }; + global.wp = window.wp; + window.hCaptchaBindEvents = jest.fn(); + + require( '../../../assets/js/hcaptcha-wpdiscuz-comment.js' ); + } + + beforeEach( () => { + loadWpDiscuz(); + } ); + + afterEach( () => { + $( document ).off( 'ajaxSuccess' ); + window.MutationObserver = originalMutationObserver; + global.MutationObserver = originalMutationObserver; + delete window.wp; + delete global.wp; + delete window.hCaptchaBindEvents; + document.body.innerHTML = ''; + jest.restoreAllMocks(); + } ); + + test( 'registers wpDiscuz ajax submit buttons and observes added forms', () => { + domReadyCallback(); + const callback = window.wp.hooks.addFilter.mock.calls[ 0 ][ 2 ]; + const submitButton = document.createElement( 'button' ); + const otherButton = document.createElement( 'button' ); + const matchingNode = document.createElement( 'form' ); + const otherNode = document.createElement( 'form' ); + const textNode = document.createTextNode( 'text' ); + + submitButton.classList.add( 'wc_comm_submit' ); + expect( callback( false, submitButton ) ).toBe( true ); + expect( callback( false, otherButton ) ).toBe( false ); + expect( callback( true, otherButton ) ).toBe( true ); + expect( observers[ 0 ].observe ).toHaveBeenCalledWith( + document.getElementById( 'wpd-threads' ), + { + childList: true, + subtree: true, + }, + ); + + matchingNode.className = 'wpd-form'; + matchingNode.innerHTML = '
'; + otherNode.className = 'wpd-form'; + + observers[ 0 ].callback( [ { addedNodes: [ textNode, otherNode, matchingNode ] } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'returns when wpDiscuz threads container is absent', () => { + loadWpDiscuz( '
' ); + + expect( () => domReadyCallback() ).not.toThrow(); + expect( observers ).toHaveLength( 0 ); + } ); + + test( 'rebinds hCaptcha after wpDiscuz add-comment ajax success only', () => { + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'action=other_action' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'action=wpdAddComment' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/hcaptcha-wpforms.test.js b/tests/js/assets-js-files/hcaptcha-wpforms.test.js new file mode 100644 index 000000000..5685a3858 --- /dev/null +++ b/tests/js/assets-js-files/hcaptcha-wpforms.test.js @@ -0,0 +1,74 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +import $ from 'jquery'; + +function installJQueryReadyProxy() { + const originalJQuery = global.jQuery || $; + const proxy = ( selector, context ) => { + if ( typeof selector === 'function' ) { + proxy.readyCallback = selector; + + return originalJQuery( document ); + } + + return originalJQuery( selector, context ); + }; + + Object.assign( proxy, originalJQuery ); + proxy.fn = originalJQuery.fn; + global.jQuery = proxy; + global.$ = proxy; + window.jQuery = proxy; + window.$ = proxy; + + return originalJQuery; +} + +describe( 'hCaptcha WPForms', () => { + let originalJQuery; + + beforeEach( () => { + jest.resetModules(); + originalJQuery = installJQueryReadyProxy(); + window.hCaptchaBindEvents = jest.fn(); + delete window.hCaptchaWPForms; + + require( '../../../assets/js/hcaptcha-wpforms.js' ); + } ); + + afterEach( () => { + $( document ).off( 'ajaxSuccess' ); + global.jQuery = originalJQuery; + global.$ = originalJQuery; + window.jQuery = originalJQuery; + window.$ = originalJQuery; + delete window.hCaptchaWPForms; + delete window.hCaptchaBindEvents; + jest.restoreAllMocks(); + } ); + + test( 'registers ready callback and rebinds after WPForms ajax success only', () => { + expect( window.hCaptchaWPForms ).toBeDefined(); + expect( global.jQuery.readyCallback ).toBeUndefined(); + + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'action=other_action' } ] ); + expect( window.hCaptchaBindEvents ).not.toHaveBeenCalled(); + + $( document ).trigger( 'ajaxSuccess', [ {}, { data: 'action=wpforms_submit' } ] ); + expect( window.hCaptchaBindEvents ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'reuses existing app object', () => { + jest.resetModules(); + const existingApp = { + init: jest.fn(), + }; + + window.hCaptchaWPForms = existingApp; + + require( '../../../assets/js/hcaptcha-wpforms.js' ); + + expect( window.hCaptchaWPForms ).toBe( existingApp ); + expect( existingApp.init ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/tests/js/assets-js-files/integrations.test.js b/tests/js/assets-js-files/integrations.test.js index 4f7ccdc7d..8d1433c56 100644 --- a/tests/js/assets-js-files/integrations.test.js +++ b/tests/js/assets-js-files/integrations.test.js @@ -1041,3 +1041,123 @@ describe( 'swapThemes isolated', () => { expect( $inactiveTable.find( 'img[data-entity="theme"][data-label="Some Theme"]' ).length ).toBe( 0 ); } ); } ); + +describe( 'integrations edge branch coverage', () => { + function resetDom( custom = {} ) { + document.body.innerHTML = getDom(); + Object.assign( window.HCaptchaIntegrationsObject, { + ...global.HCaptchaIntegrationsObject, + ...custom, + } ); + window.hCaptchaIntegrations( $ ); + } + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + test( 'setupHelpers returns when antispam coverage checkbox is disabled', () => { + document.body.innerHTML = getDom(); + document.getElementById( 'show_antispam_coverage_1' ).disabled = true; + window.hCaptchaIntegrations( $ ); + + expect( $( '#antispam-label' ).next( '.helper' ).length ).toBe( 0 ); + } ); + + test( 'setupHelper tolerates missing labels and duplicate antispam attributes', () => { + document.body.innerHTML = getDom(); + const originalGet = $.fn.get; + let labelCalls = 0; + jest.spyOn( $.fn, 'get' ).mockImplementation( function( index ) { + if ( this.is( 'label[data-antispam]' ) ) { + labelCalls++; + + if ( labelCalls === 1 ) { + return undefined; + } + + return { + attributes: [ + { name: 'data-antispam-fst' }, + { name: 'data-antispam-fst' }, + ], + }; + } + + return originalGet.call( this, index ); + } ); + document.getElementById( 'antispam-label' ).insertAdjacentHTML( 'afterend', '' ); + + window.hCaptchaIntegrations( $ ); + + expect( labelCalls ).toBeGreaterThan( 1 ); + } ); + + test( 'deactivate theme uses empty newTheme when dialog select value is undefined', () => { + resetDom(); + window.kaggDialog = { + confirm: jest.fn( ( cfg ) => { + const querySelectorSpy = jest.spyOn( document, 'querySelector' ).mockImplementation( ( selector ) => { + if ( selector === '.kagg-dialog select' ) { + return { value: undefined }; + } + + return Document.prototype.querySelector.call( document, selector ); + } ); + cfg.onAction( true ); + querySelectorSpy.mockRestore(); + } ), + }; + const calls = []; + jest.spyOn( $, 'post' ).mockImplementation( ( opts ) => { + calls.push( opts ); + const deferred = $.Deferred(); + deferred.resolve( { success: true, data: { message: 'ok', stati: [] } } ); + + return deferred; + } ); + + $( '.hcaptcha-integrations-twentytwentyone-theme img' ).trigger( 'click' ); + + expect( calls[ 0 ].data.newTheme ).toBe( '' ); + } ); + + test( 'updateActivationStati moves inactive rows into active table and handles missing alt text', () => { + resetDom(); + const inactiveTable = $( '.form-table' ).eq( 2 ); + const activeTable = $( '.form-table' ).eq( 1 ); + const row = $( '.hcaptcha-integrations-other-plugin' ); + activeTable.find( 'img' ).first().removeAttr( 'alt' ); + inactiveTable.find( 'tbody' ).append( row ); + window.kaggDialog = { confirm: jest.fn( ( cfg ) => cfg.onAction( true ) ) }; + const deferred = $.Deferred(); + jest.spyOn( $, 'post' ).mockImplementation( () => deferred ); + + row.find( 'img' ).trigger( 'click' ); + deferred.resolve( { success: true, data: { message: 'ok', stati: { other_plugin: true } } } ); + + expect( row.closest( '.form-table' )[ 0 ] ).toBe( activeTable[ 0 ] ); + } ); + + test( 'plugin image without a status class still sends an empty status value', () => { + document.body.innerHTML = getDom(); + document.querySelectorAll( '.form-table' )[ 1 ].querySelector( 'tbody' ).insertAdjacentHTML( + 'beforeend', + '
', + ); + window.hCaptchaIntegrations( $ ); + window.kaggDialog = { confirm: jest.fn( ( cfg ) => cfg.onAction( true ) ) }; + const calls = []; + jest.spyOn( $, 'post' ).mockImplementation( ( opts ) => { + calls.push( opts ); + const deferred = $.Deferred(); + deferred.resolve( { success: true, data: { message: 'ok', stati: [] } } ); + + return deferred; + } ); + + $( '.plain-plugin-row img' ).trigger( 'click' ); + + expect( calls[ 0 ].data.status ).toBe( '' ); + } ); +} ); diff --git a/tests/js/assets-js-files/kagg-dialog.test.js b/tests/js/assets-js-files/kagg-dialog.test.js index 7e66e1d64..fa4d281b1 100644 --- a/tests/js/assets-js-files/kagg-dialog.test.js +++ b/tests/js/assets-js-files/kagg-dialog.test.js @@ -129,3 +129,33 @@ describe( 'kagg-dialog.js', () => { spy.mockRestore(); } ); } ); + +describe( 'kagg-dialog defaults and custom parse coverage', () => { + beforeEach( () => { + jest.resetModules(); + document.body.innerHTML = '
'; + require( '../../../assets/js/kagg-dialog.js' ); + } ); + + test( 'default no-op callbacks and ready can be invoked directly', () => { + expect( () => window.kaggDialog.defaults.defaultButtons.ok.action() ).not.toThrow(); + expect( () => window.kaggDialog.defaults.defaultButtons.cancel.action() ).not.toThrow(); + expect( () => window.kaggDialog.ready() ).not.toThrow(); + } ); + + test( 'parseSettings leaves unknown custom buttons untouched', () => { + const action = jest.fn(); + + window.kaggDialog.parseSettings( { + buttons: { + extra: { + text: 'Extra', + class: 'btn-extra', + action, + }, + }, + } ); + + expect( window.kaggDialog.settings.buttons.extra.action ).toBe( action ); + } ); +} ); diff --git a/tests/js/assets-js-files/notifications.test.js b/tests/js/assets-js-files/notifications.test.js index 6709278d8..3471f74c9 100644 --- a/tests/js/assets-js-files/notifications.test.js +++ b/tests/js/assets-js-files/notifications.test.js @@ -284,3 +284,53 @@ describe( 'notifications.js', () => { expect( navSpan.style.display ).toBe( 'none' ); } ); } ); + +describe( 'notifications edge branch coverage', () => { + afterEach( () => { + jest.restoreAllMocks(); + } ); + + test( 'normalizeNotificationHeight keeps all notifications hidden when visible index disappears', () => { + bootNotifications( { withTwo: true } ); + let notificationCalls = 0; + const wrappedJQuery = ( selector, context ) => { + if ( selector === 'div.hcaptcha-notification' ) { + notificationCalls++; + + if ( notificationCalls === 3 ) { + return $( [] ); + } + } + + return $( selector, context ); + }; + Object.assign( wrappedJQuery, $ ); + wrappedJQuery.fn = $.fn; + + window.hCaptchaNotifications( wrappedJQuery ); + + expect( notificationCalls ).toBeGreaterThanOrEqual( 3 ); + } ); + + test( 'initializer can run without the Jest-only test hook', () => { + const jestRef = jest; + const savedJest = global.jest; + + document.body.innerHTML = getDom( { withTwo: true } ); + Object.assign( window.HCaptchaNotificationsObject, defaultNotifications ); + Object.defineProperty( global, 'jest', { + configurable: true, + value: undefined, + } ); + jestRef.isolateModules( () => { + require( '../../../assets/js/notifications.js' ); + } ); + window.hCaptchaNotifications( $ ); + Object.defineProperty( global, 'jest', { + configurable: true, + value: savedJest, + } ); + + expect( document.getElementById( 'hcaptcha-navigation-pages' ).textContent ).toBe( '2' ); + } ); +} ); diff --git a/tests/js/assets-js-files/onboarding-wizard.test.js b/tests/js/assets-js-files/onboarding-wizard.test.js index 5c4a2bd8c..901d9bced 100644 --- a/tests/js/assets-js-files/onboarding-wizard.test.js +++ b/tests/js/assets-js-files/onboarding-wizard.test.js @@ -1,3 +1,5 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + import $ from 'jquery'; global.jQuery = $; @@ -576,7 +578,7 @@ describe( 'onboarding.js', () => { test( 'tooltip top is clamped to 10 when it would go above the viewport', () => { jest.spyOn( $.fn, 'offset' ).mockRestore(); - // target is at top:0 and tooltip height is 50 → top = 0 - 25 + 0 = -25 → clamped to 10 + // Target is at top:0, and tooltip height is 50 → top = 0 - 25 + 0 = -25 → clamped to 10 jest.spyOn( $.fn, 'offset' ).mockImplementation( () => ( { top: 0, left: 0 } ) ); jest.spyOn( $.fn, 'outerHeight' ).mockRestore(); jest.spyOn( $.fn, 'outerHeight' ).mockImplementation( () => 50 ); @@ -741,7 +743,7 @@ describe( 'onboarding.js', () => { expect( document.body.textContent ).toContain( 'Congrats — setup complete!' ); - // Dismiss the congrats modal → triggers the resubmit. + // Dismiss the congrats modal → triggers the resubmitting. $( '.hcap-onb-modal-overlay' ).trigger( 'click' ); jest.runAllTimers(); @@ -808,7 +810,7 @@ describe( 'onboarding.js', () => { } ); test( 'automatic setup falls back to window.location.href when form element is absent', () => { - // Boot with step 3 but no #hcaptcha-options form in DOM → form is null → setLocationHref is called. + // Boot with step 3, but no #hcaptcha-options form in DOM → form is null → setLocationHref is called. bootOnboarding( { currentStep: 'step 3' }, `
@@ -887,7 +889,7 @@ describe( 'onboarding.js', () => { // ─── init: early return when step belongs to the other page ─────────────── test( 'init returns early without panel when current step belongs to the other page', () => { - // General page at step 7 (integrations step) → early return after goIntegrations check. + // General page at step 7 (integration step) → early return after goIntegrations check. bootOnboarding( { page: 'general', currentStep: 'step 7' } ); expect( $( '.hcap-onb-panel' ).length ).toBe( 0 ); @@ -1002,4 +1004,219 @@ describe( 'onboarding.js', () => { Storage.prototype.getItem.mockRestore(); } ); + test( 'welcome proceed uses step 1 when currentStep is empty', () => { + bootOnboarding( { currentStep: '' } ); + + $( '.hcap-onb-go' ).trigger( 'click' ); + jest.runOnlyPendingTimers(); + + expect( $( '.hcap-onb-panel [data-step="1"]' ).hasClass( 'current' ) ).toBe( true ); + } ); + + test( 'panel navigation builds URLs without empty params', () => { + bootOnboarding( { + stepNonce: '', + } ); + + $( '.hcap-onb-list li[data-step="2"]' ).trigger( 'click' ); + + expect( locationHref ).toContain( 'onboarding_step=2' ); + expect( locationHref ).not.toContain( 'hcaptcha_onboarding_nonce=' ); + } ); + + test( 'panel navigation falls back to window.location.href when no test setter is installed', () => { + jest.resetModules(); + $( document ).off(); + $( window ).off(); + document.body.innerHTML = getGeneralDom(); + window.HCaptchaOnboardingObject = { + ...defaultOnboardingObject, + currentStep: 'step 3', + }; + require( '../../../assets/js/onboarding-wizard.js' ); + window.hCaptchaOnboarding( $ ); + jest.runOnlyPendingTimers(); + delete window.hCaptchaOnboarding.setLocationHref; + + expect( () => { + $( '.hcap-onb-list li[data-step="2"]' ).trigger( 'click' ); + } ).not.toThrow(); + } ); + + test( 'video URL conversion handles embed encoding errors', () => { + const badUrl = 'https://youtu.be/\uD800'; + + bootOnboarding( { + currentStep: 'step 1', + videoUrl: badUrl, + } ); + $( '.hcap-onb-go' ).trigger( 'click' ); + jest.runOnlyPendingTimers(); + $( '.hcap-onb-video-link' ).trigger( 'click' ); + + expect( $( '.hcap-onb-modal.video iframe' ).attr( 'src' ) ).toBe( badUrl ); + } ); + + test( 'done on a step for a now-different page leaves only the panel visible', () => { + bootOnboarding( { currentStep: 'step 4' } ); + window.HCaptchaOnboardingObject.page = 'integrations'; + + $( '.hcap-onb-done' ).trigger( 'click' ); + + expect( $( '.hcap-onb-panel' ).length ).toBe( 1 ); + expect( $( '.hcap-onb-tip' ).length ).toBe( 0 ); + } ); + + test( 'done on step 4 advances to step 5', () => { + bootOnboarding( { currentStep: 'step 4' } ); + + $( '.hcap-onb-done' ).trigger( 'click' ); + + expect( postSpy ).toHaveBeenCalledWith( + 'https://test.test/wp-admin/admin-ajax.php', + expect.objectContaining( { value: 'step 5' } ), + ); + expect( $( '.hcap-onb-panel [data-step="5"]' ).hasClass( 'current' ) ).toBe( true ); + } ); + + test( 'showStep skips scrollIntoView when it is unavailable', () => { + Element.prototype.scrollIntoView = undefined; + + expect( () => { + bootOnboarding( { currentStep: 'step 4' } ); + jest.runAllTimers(); + } ).not.toThrow(); + } ); + + test( 'positioning timer returns when Escape removes tooltip first', () => { + bootOnboarding( { currentStep: 'step 4' } ); + + $( document ).trigger( $.Event( 'keydown', { key: 'Escape' } ) ); + + expect( () => jest.runAllTimers() ).not.toThrow(); + expect( $( '.hcap-onb-tip' ).length ).toBe( 0 ); + } ); + + test( 'resize uses step 1 fallback when currentStep is empty', () => { + bootOnboarding( { currentStep: 'step 4' } ); + window.HCaptchaOnboardingObject.currentStep = ''; + + $( window ).trigger( 'resize.hcapOnb' ); + jest.runAllTimers(); + + expect( $( '.hcap-onb-tip-text' ).first().text() ).toBe( 'Get your keys at hcaptcha.com' ); + } ); + + test( 'automatic setup continues when sessionStorage is unavailable', () => { + bootOnboarding(); + const descriptor = Object.getOwnPropertyDescriptor( window, 'sessionStorage' ); + Object.defineProperty( window, 'sessionStorage', { + configurable: true, + value: undefined, + } ); + const form = document.getElementById( 'hcaptcha-options' ); + form.submit = jest.fn(); + + $( '.hcap-onb-done' ).trigger( 'click' ); + $( '.hcap-onb-auto-setup' ).trigger( 'click' ); + + expect( form.submit ).toHaveBeenCalledTimes( 1 ); + Object.defineProperty( window, 'sessionStorage', descriptor ); + } ); + + test( 'form submit step 6 continues when sessionStorage is unavailable', () => { + bootOnboarding( { currentStep: 'step 6' } ); + const descriptor = Object.getOwnPropertyDescriptor( window, 'sessionStorage' ); + Object.defineProperty( window, 'sessionStorage', { + configurable: true, + value: undefined, + } ); + + expect( () => { + $( '#hcaptcha-options' ).trigger( 'submit' ); + } ).not.toThrow(); + + Object.defineProperty( window, 'sessionStorage', descriptor ); + } ); + + test( 'form submit step 8 returns immediately after submitting flag is set', () => { + bootOnboarding( { page: 'integrations', currentStep: 'step 8' }, getIntegrationsDom() ); + const form = document.getElementById( 'hcaptcha-options' ); + form.submit = jest.fn(); + + $( '#hcaptcha-options' ).trigger( 'submit' ); + $( '.hcap-onb-modal-overlay' ).trigger( 'click' ); + jest.runAllTimers(); + window.HCaptchaOnboardingObject.currentStep = 'step 8'; + const event = $.Event( 'submit' ); + event.preventDefault = jest.fn(); + + $( '#hcaptcha-options' ).trigger( event ); + + expect( event.preventDefault ).not.toHaveBeenCalled(); + } ); + test( 'video URL conversion handles empty URL fallback', () => { + bootOnboarding( { + currentStep: 'step 1', + videoUrl: '', + } ); + $( '.hcap-onb-go' ).trigger( 'click' ); + jest.runOnlyPendingTimers(); + $( '.hcap-onb-video-link' ).trigger( 'click' ); + + expect( $( '.hcap-onb-modal.video iframe' ).attr( 'src' ) ).toBe( '' ); + } ); + + test( 'positioning timer returns when tooltip is removed before it runs', () => { + bootOnboarding( { currentStep: 'step 4' } ); + $( document ).trigger( $.Event( 'keydown', { key: 'Escape' } ) ); + + expect( () => jest.advanceTimersByTime( 180 ) ).not.toThrow(); + expect( $( '.hcap-onb-tip' ).length ).toBe( 0 ); + } ); + + test( 'tooltip positioning keeps left value when it fits viewport', () => { + const widthSpy = jest.spyOn( $.fn, 'width' ).mockImplementation( function() { + if ( this.get( 0 ) === window ) { + return 1000; + } + + return 100; + } ); + + bootOnboarding( { currentStep: 'step 4' } ); + jest.runOnlyPendingTimers(); + + expect( parseInt( $( '.hcap-onb-tip' ).css( 'left' ), 10 ) ).toBeGreaterThan( 0 ); + widthSpy.mockRestore(); + } ); + + test( 'form submit uses step 1 fallback when currentStep is empty', () => { + bootOnboarding( { currentStep: 'step 4' } ); + window.HCaptchaOnboardingObject.currentStep = ''; + postSpy.mockClear(); + + $( '#hcaptcha-options' ).trigger( 'submit' ); + + expect( postSpy ).not.toHaveBeenCalled(); + } ); + test( 'positioning timer exits when tooltip is removed before first positioning tick', () => { + jest.resetModules(); + $( document ).off(); + $( window ).off(); + document.body.innerHTML = getGeneralDom(); + window.HCaptchaOnboardingObject = { + ...defaultOnboardingObject, + currentStep: 'step 4', + }; + require( '../../../assets/js/onboarding-wizard.js' ); + window.hCaptchaOnboarding.setLocationHref = ( url ) => { + locationHref = url; + }; + window.hCaptchaOnboarding( $ ); + + $( document ).trigger( $.Event( 'keydown', { key: 'Escape' } ) ); + expect( () => jest.advanceTimersByTime( 180 ) ).not.toThrow(); + expect( $( '.hcap-onb-tip' ).length ).toBe( 0 ); + } ); } ); diff --git a/tests/js/assets-js-files/settings-base.test.js b/tests/js/assets-js-files/settings-base.test.js index c9cff49ad..35080c68e 100644 --- a/tests/js/assets-js-files/settings-base.test.js +++ b/tests/js/assets-js-files/settings-base.test.js @@ -532,3 +532,93 @@ describe( 'ajaxPrefilter', () => { expect( fd.get( '_wp_http_referer' ) ).toBe( 'existing' ); } ); } ); + +describe( 'additional settings-base coverage', () => { + afterEach( () => { + $( document ).off(); + jest.useRealTimers(); + jest.dontMock( '../../../assets/js/hcaptcha-helper.js' ); + } ); + + test( 'hash links ignore empty and off-page href values', () => { + bootSettingsBase(); + document.body.insertAdjacentHTML( 'beforeend', 'emptyexternal' ); + const emptyEvent = $.Event( 'click' ); + const externalEvent = $.Event( 'click' ); + + $( '#hash-empty' ).trigger( emptyEvent ); + $( '#hash-external' ).trigger( externalEvent ); + + expect( emptyEvent.isDefaultPrevented() ).toBe( false ); + expect( externalEvent.isDefaultPrevented() ).toBe( false ); + } ); + + test( 'same-page hash links prevent default, push history, and highlight target', () => { + jest.useFakeTimers(); + bootSettingsBase(); + document.body.insertAdjacentHTML( 'beforeend', '
target' ); + const pushStateSpy = jest.spyOn( window.history, 'pushState' ); + const event = $.Event( 'click' ); + + $( '#hash-link' ).trigger( event ); + jest.runAllTimers(); + + expect( event.isDefaultPrevented() ).toBe( true ); + expect( pushStateSpy ).toHaveBeenCalledWith( null, '', '#hash-target' ); + expect( document.getElementById( 'hash-target' ).classList.contains( 'blink' ) ).toBe( true ); + pushStateSpy.mockRestore(); + } ); + + test( 'same-page hash link skips pushState when hash is unchanged', () => { + jest.useFakeTimers(); + window.location.hash = '#hash-same'; + bootSettingsBase(); + document.body.insertAdjacentHTML( 'beforeend', '
target' ); + const pushStateSpy = jest.spyOn( window.history, 'pushState' ); + + $( '#hash-same-link' ).trigger( 'click' ); + jest.runAllTimers(); + + expect( pushStateSpy ).not.toHaveBeenCalled(); + expect( document.getElementById( 'hash-same' ).classList.contains( 'blink' ) ).toBe( true ); + pushStateSpy.mockRestore(); + window.location.hash = ''; + } ); + + test( 'hashchange highlights matching elements', () => { + jest.useFakeTimers(); + bootSettingsBase(); + document.body.insertAdjacentHTML( 'beforeend', '
' ); + + window.location.hash = '#hash-change-target'; + window.dispatchEvent( new Event( 'hashchange' ) ); + jest.runAllTimers(); + + expect( document.getElementById( 'hash-change-target' ).classList.contains( 'blink' ) ).toBe( true ); + window.location.hash = ''; + } ); + + test( 'ajax prefilter creates query data when hCaptcha action has no data payload', () => { + let settingsBasePrefilter; + const originalAjaxPrefilter = $.ajaxPrefilter; + + jest.resetModules(); + jest.doMock( '../../../assets/js/hcaptcha-helper.js', () => ( { + helper: { + getAction: () => 'hcaptcha_mock', + }, + } ) ); + document.body.innerHTML = getDom(); + Object.assign( window.HCaptchaSettingsBaseObject, defaultSettingsBaseObject ); + $.ajaxPrefilter = ( dataTypes, handler ) => { + settingsBasePrefilter = typeof dataTypes === 'function' ? dataTypes : handler; + }; + require( '../../../assets/js/settings-base.js' ); + $.ajaxPrefilter = originalAjaxPrefilter; + + const options = { url: 'https://test.test/wp-admin/admin-ajax.php' }; + settingsBasePrefilter( options, {}, null ); + + expect( options.data ).toContain( '_wp_http_referer=' ); + } ); +} ); diff --git a/tests/js/assets-js-files/support-modal.test.js b/tests/js/assets-js-files/support-modal.test.js new file mode 100644 index 000000000..79b080996 --- /dev/null +++ b/tests/js/assets-js-files/support-modal.test.js @@ -0,0 +1,587 @@ +// noinspection JSUnresolvedFunction,JSUnresolvedVariable + +const defaultSupportModalObject = { + githubIssueUrl: 'https://github.com/hCaptcha/hcaptcha-wordpress-plugin/issues/new', + wordpressSupportUrl: 'https://wordpress.org/support/plugin/hcaptcha-for-forms-and-more/#new-topic-0', + systemInfo: '### Begin System Info ###\n\n-- hCaptcha Info --\n\nVersion: 5.0.0\n\n### End System Info ###', + strings: { + copySuccess: 'Report copied to clipboard.', + copyError: 'Cannot copy the report automatically. Please copy it from the report field.', + summaryRequired: 'Please enter a summary before continuing.', + openFailed: 'Your browser blocked the new tab. Please allow popups and try again.', + emptyValue: 'Not provided', + report: { + summary: 'Summary', + affectedArea: 'Affected area', + steps: 'Steps to reproduce', + expected: 'Expected behavior', + actual: 'Actual behavior', + additional: 'Additional details', + diagnostics: 'Diagnostic information', + feature: 'Feature request', + problem: 'Problem / use case', + solution: 'Proposed solution', + alternatives: 'Alternative considered / notes', + question: 'Question', + configure: 'What I am trying to configure', + tried: 'What I already tried', + }, + }, +}; + +function getDom() { + return ` + + + `.trim(); +} + +function bootSupportModal( objectOverrides = {} ) { + jest.resetModules(); + document.body.innerHTML = getDom(); + + const config = { + ...defaultSupportModalObject, + ...objectOverrides, + }; + + global.HCaptchaSupportModalObject = config; + window.HCaptchaSupportModalObject = config; + + require( '../../../assets/js/support-modal.js' ); + window.hCaptchaSupportModal(); +} + +function flushPromises() { + return new Promise( ( resolve ) => { + setTimeout( resolve, 0 ); + } ); +} + +describe( 'support-modal.js', () => { + let clipboardSpy; + let openSpy; + + beforeEach( () => { + jest.clearAllMocks(); + clipboardSpy = jest.fn().mockResolvedValue(); + Object.defineProperty( window.navigator, 'clipboard', { + value: { writeText: clipboardSpy }, + configurable: true, + } ); + openSpy = jest.spyOn( window, 'open' ).mockImplementation( () => ( {} ) ); + } ); + + afterEach( () => { + openSpy.mockRestore(); + } ); + + test( 'returns early when modal element is absent', () => { + jest.resetModules(); + document.body.innerHTML = ''; + window.HCaptchaSupportModalObject = defaultSupportModalObject; + + expect( () => { + require( '../../../assets/js/support-modal.js' ); + window.hCaptchaSupportModal(); + } ).not.toThrow(); + } ); + + test( 'opens modal, focuses summary, and builds a bug report', () => { + bootSupportModal(); + + document.querySelector( '.hcaptcha-help-button' ).click(); + + const modal = document.getElementById( 'hcaptcha-support-modal' ); + const report = document.getElementById( 'hcaptcha-support-report' ).value; + + expect( modal.hidden ).toBe( false ); + expect( document.body.classList.contains( 'hcaptcha-support-modal-open' ) ).toBe( true ); + expect( document.activeElement ).toBe( document.getElementById( 'hcaptcha-support-summary' ) ); + expect( report ).toContain( '## Summary' ); + expect( report ).toContain( '## Diagnostic information' ); + expect( report ).toContain( '### Begin System Info ###' ); + expect( report ).toContain( 'Version: 5.0.0' ); + expect( document.querySelector( '[data-hcaptcha-support-action="github"] .hcaptcha-support-recommended' ).hidden ).toBe( false ); + expect( document.querySelector( '[data-hcaptcha-support-action="wordpress"] .hcaptcha-support-recommended' ).hidden ).toBe( true ); + expect( document.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ).disabled ).toBe( true ); + } ); + + test( 'updates visible fields and report for feature requests', () => { + bootSupportModal(); + + document.querySelector( 'input[value="feature"]' ).checked = true; + document.getElementById( 'hcaptcha-support-summary' ).value = 'Add a widget option'; + document.getElementById( 'hcaptcha-support-problem' ).value = 'Widget forms are not covered'; + document.getElementById( 'hcaptcha-support-solution' ).value = 'Add a widget toggle'; + document.getElementById( 'hcaptcha-support-alternatives' ).value = 'Shortcode workaround'; + document.getElementById( 'hcaptcha-support-details' ).value = 'More context'; + document.getElementById( 'hcaptcha-support-modal' ).dispatchEvent( new Event( 'change', { bubbles: true } ) ); + + const report = document.getElementById( 'hcaptcha-support-report' ).value; + + expect( document.querySelector( '[data-hcaptcha-support-fields="feature"]' ).hidden ).toBe( false ); + expect( document.querySelector( '[data-hcaptcha-support-fields="bug"]' ).hidden ).toBe( true ); + expect( report ).toContain( '## Feature request' ); + expect( report ).toContain( 'Add a widget option' ); + expect( report ).toContain( '## Alternative considered / notes\n\nShortcode workaround' ); + expect( report ).toContain( '## Additional details\n\nMore context' ); + expect( report.indexOf( '## Affected area' ) ).toBeLessThan( report.indexOf( '## Problem / use case' ) ); + expect( report.indexOf( '## Problem / use case' ) ).toBeLessThan( report.indexOf( '## Proposed solution' ) ); + expect( report.indexOf( '## Proposed solution' ) ).toBeLessThan( report.indexOf( '## Alternative considered / notes' ) ); + expect( report.indexOf( '## Alternative considered / notes' ) ).toBeLessThan( report.indexOf( '## Additional details' ) ); + expect( document.querySelector( '[data-hcaptcha-support-action="github"] .hcaptcha-support-recommended' ).hidden ).toBe( false ); + } ); + + test( 'recommends WordPress.org for setup questions and includes system information when selected', () => { + bootSupportModal(); + + document.querySelector( 'input[value="support"]' ).checked = true; + document.getElementById( 'hcaptcha-support-summary' ).value = 'Need setup help'; + document.getElementById( 'hcaptcha-support-configure' ).value = 'WooCommerce checkout'; + document.getElementById( 'hcaptcha-support-tried' ).value = 'Enabled integration toggle'; + document.getElementById( 'hcaptcha-support-details' ).value = 'Captcha appears twice'; + document.getElementById( 'hcaptcha-support-modal' ).dispatchEvent( new Event( 'change', { bubbles: true } ) ); + + const report = document.getElementById( 'hcaptcha-support-report' ).value; + + expect( document.querySelector( '[data-hcaptcha-support-action="github"] .hcaptcha-support-recommended' ).hidden ).toBe( true ); + expect( document.querySelector( '[data-hcaptcha-support-action="wordpress"] .hcaptcha-support-recommended' ).hidden ).toBe( false ); + expect( report ).toContain( '## Question' ); + expect( report ).toContain( '## Additional details\n\nCaptcha appears twice' ); + expect( report.indexOf( '## Affected area' ) ).toBeLessThan( report.indexOf( '## What I am trying to configure' ) ); + expect( report.indexOf( '## What I am trying to configure' ) ).toBeLessThan( report.indexOf( '## What I already tried' ) ); + expect( report.indexOf( '## What I already tried' ) ).toBeLessThan( report.indexOf( '## Additional details' ) ); + expect( report ).toContain( '## Diagnostic information' ); + expect( report ).toContain( '### Begin System Info ###' ); + } ); + + test( 'removes system information from report when unchecked', () => { + bootSupportModal(); + + const checkbox = document.getElementById( 'hcaptcha-support-include-system-info' ); + + checkbox.checked = false; + document.getElementById( 'hcaptcha-support-modal' ).dispatchEvent( new Event( 'change', { bubbles: true } ) ); + + const report = document.getElementById( 'hcaptcha-support-report' ).value; + + expect( report ).not.toContain( '## Diagnostic information' ); + expect( report ).not.toContain( '### Begin System Info ###' ); + } ); + test( 'toggles action recommendation help details', () => { + bootSupportModal(); + + const githubAction = document.querySelector( '[data-hcaptcha-support-action="github"]' ); + const wordpressAction = document.querySelector( '[data-hcaptcha-support-action="wordpress"]' ); + const githubHelp = githubAction.querySelector( '.hcaptcha-support-action-help' ); + const wordpressHelp = wordpressAction.querySelector( '.hcaptcha-support-action-help' ); + + githubHelp.click(); + + expect( githubAction.classList.contains( 'is-description-open' ) ).toBe( true ); + expect( githubHelp.getAttribute( 'aria-expanded' ) ).toBe( 'true' ); + + wordpressHelp.click(); + + expect( githubAction.classList.contains( 'is-description-open' ) ).toBe( false ); + expect( githubHelp.getAttribute( 'aria-expanded' ) ).toBe( 'false' ); + expect( wordpressAction.classList.contains( 'is-description-open' ) ).toBe( true ); + expect( wordpressHelp.getAttribute( 'aria-expanded' ) ).toBe( 'true' ); + + document.querySelector( '[data-hcaptcha-support-copy]' ).click(); + + expect( wordpressAction.classList.contains( 'is-description-open' ) ).toBe( false ); + expect( wordpressHelp.getAttribute( 'aria-expanded' ) ).toBe( 'false' ); + } ); + + test( 'copy button writes report to clipboard and shows success status', async () => { + bootSupportModal(); + + document.getElementById( 'hcaptcha-support-summary' ).value = 'Broken login captcha'; + document.querySelector( '[data-hcaptcha-support-copy]' ).click(); + await flushPromises(); + + const wordpressButton = document.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ); + + expect( clipboardSpy ).toHaveBeenCalledWith( expect.stringContaining( 'Broken login captcha' ) ); + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( 'Report copied to clipboard.' ); + expect( document.getElementById( 'hcaptcha-support-status' ).className ).toBe( 'hcaptcha-support-status is-success' ); + expect( wordpressButton.disabled ).toBe( false ); + + document.getElementById( 'hcaptcha-support-details' ).value = 'New detail'; + document.getElementById( 'hcaptcha-support-modal' ).dispatchEvent( new Event( 'input', { bubbles: true } ) ); + + expect( wordpressButton.disabled ).toBe( true ); + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( '' ); + expect( document.getElementById( 'hcaptcha-support-status' ).className ).toBe( 'hcaptcha-support-status' ); + } ); + + test( 'GitHub action requires summary before opening', () => { + bootSupportModal(); + + document.querySelector( '[data-hcaptcha-support-continue="github"]' ).click(); + + expect( openSpy ).not.toHaveBeenCalled(); + expect( document.getElementById( 'hcaptcha-support-summary' ).getAttribute( 'aria-invalid' ) ).toBe( 'true' ); + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( 'Please enter a summary before continuing.' ); + expect( document.getElementById( 'hcaptcha-support-status' ).className ).toBe( 'hcaptcha-support-status is-error' ); + } ); + + test( 'GitHub action opens issue URL with title and body', () => { + bootSupportModal(); + + document.getElementById( 'hcaptcha-support-summary' ).value = 'Broken login captcha'; + document.getElementById( 'hcaptcha-support-actual' ).value = 'Challenge never renders'; + document.querySelector( '[data-hcaptcha-support-continue="github"]' ).click(); + + const openedUrl = new URL( openSpy.mock.calls[ 0 ][ 0 ] ); + + expect( openedUrl.origin + openedUrl.pathname ).toBe( defaultSupportModalObject.githubIssueUrl ); + expect( openedUrl.searchParams.get( 'title' ) ).toBe( 'Broken login captcha' ); + expect( openedUrl.searchParams.get( 'body' ) ).toContain( 'Challenge never renders' ); + } ); + + test( 'WordPress.org action opens support forum after report copy step', async () => { + bootSupportModal(); + + document.getElementById( 'hcaptcha-support-summary' ).value = 'Need setup help'; + + const copyButton = document.querySelector( '[data-hcaptcha-support-copy]' ); + const wordpressButton = document.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ); + + expect( wordpressButton.disabled ).toBe( true ); + + copyButton.click(); + await flushPromises(); + + expect( wordpressButton.disabled ).toBe( false ); + + wordpressButton.click(); + await flushPromises(); + + expect( clipboardSpy ).toHaveBeenCalledTimes( 1 ); + expect( openSpy ).toHaveBeenCalledWith( defaultSupportModalObject.wordpressSupportUrl, '_blank' ); + } ); + + test( 'WordPress.org action can continue after automatic copy fails', async () => { + clipboardSpy.mockRejectedValueOnce( new Error( 'denied' ) ); + bootSupportModal(); + + document.getElementById( 'hcaptcha-support-summary' ).value = 'Need setup help'; + + const copyButton = document.querySelector( '[data-hcaptcha-support-copy]' ); + const wordpressButton = document.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ); + + copyButton.click(); + await flushPromises(); + + expect( wordpressButton.disabled ).toBe( false ); + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( 'Cannot copy the report automatically. Please copy it from the report field.' ); + expect( document.activeElement ).toBe( document.getElementById( 'hcaptcha-support-report' ) ); + + wordpressButton.click(); + + expect( openSpy ).toHaveBeenCalledWith( defaultSupportModalObject.wordpressSupportUrl, '_blank' ); + } ); + + test( 'Escape closes modal and returns focus to opener', () => { + bootSupportModal(); + + const openButton = document.querySelector( '.hcaptcha-help-button' ); + openButton.focus(); + openButton.click(); + document.dispatchEvent( new KeyboardEvent( 'keydown', { key: 'Escape' } ) ); + + expect( document.getElementById( 'hcaptcha-support-modal' ).hidden ).toBe( true ); + expect( document.activeElement ).toBe( openButton ); + } ); + test( 'handles missing optional modal elements and missing configured strings', () => { + jest.resetModules(); + document.body.innerHTML = getDom() + .replace( '', '' ) + .replace( '', '' ) + .replace( '
', '' ) + .replace( '', '' ); + const config = { + githubIssueUrl: defaultSupportModalObject.githubIssueUrl, + wordpressSupportUrl: defaultSupportModalObject.wordpressSupportUrl, + }; + global.HCaptchaSupportModalObject = config; + window.HCaptchaSupportModalObject = config; + + require( '../../../assets/js/support-modal.js' ); + + expect( () => window.hCaptchaSupportModal() ).not.toThrow(); + expect( document.querySelector( '[data-hcaptcha-support-fields="bug"]' ).hidden ).toBe( false ); + } ); + + test( 'focus trap cycles between first and last focusable controls', () => { + bootSupportModal(); + const modal = document.getElementById( 'hcaptcha-support-modal' ); + const closeButton = modal.querySelector( '[data-hcaptcha-support-close]' ); + const copyButton = modal.querySelector( '[data-hcaptcha-support-copy]' ); + + document.querySelector( '.hcaptcha-help-button' ).click(); + closeButton.focus(); + const backward = new KeyboardEvent( 'keydown', { key: 'Tab', shiftKey: true, bubbles: true } ); + const backwardPreventDefault = jest.spyOn( backward, 'preventDefault' ); + document.dispatchEvent( backward ); + + expect( backwardPreventDefault ).toHaveBeenCalled(); + expect( document.activeElement ).toBe( copyButton ); + + copyButton.focus(); + const forward = new KeyboardEvent( 'keydown', { key: 'Tab', bubbles: true } ); + const forwardPreventDefault = jest.spyOn( forward, 'preventDefault' ); + document.dispatchEvent( forward ); + + expect( forwardPreventDefault ).toHaveBeenCalled(); + expect( document.activeElement ).toBe( closeButton ); + } ); + + test( 'focus trap returns when there are no visible focusable controls', () => { + bootSupportModal(); + const modal = document.getElementById( 'hcaptcha-support-modal' ); + + document.querySelector( '.hcaptcha-help-button' ).click(); + modal.querySelectorAll( 'a, button, input, select, textarea, [tabindex]' ).forEach( ( element ) => { + element.hidden = true; + } ); + const event = new KeyboardEvent( 'keydown', { key: 'Tab', bubbles: true } ); + const preventDefault = jest.spyOn( event, 'preventDefault' ); + + document.dispatchEvent( event ); + + expect( preventDefault ).not.toHaveBeenCalled(); + } ); + + test( 'modal click handler ignores non-element targets and orphan help buttons', () => { + bootSupportModal(); + const modal = document.getElementById( 'hcaptcha-support-modal' ); + const textNode = document.createTextNode( 'text target' ); + const orphanHelp = document.createElement( 'button' ); + + modal.appendChild( textNode ); + textNode.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); + + orphanHelp.type = 'button'; + orphanHelp.className = 'hcaptcha-support-action-help'; + modal.appendChild( orphanHelp ); + expect( () => orphanHelp.click() ).not.toThrow(); + } ); + + test( 'clicking inside an action description keeps the current action tip open', () => { + bootSupportModal(); + const action = document.querySelector( '[data-hcaptcha-support-action="github"]' ); + const help = action.querySelector( '.hcaptcha-support-action-help' ); + const description = action.querySelector( '.hcaptcha-support-action-description' ); + + help.click(); + description.click(); + + expect( action.classList.contains( 'is-description-open' ) ).toBe( true ); + } ); + + test( 'WordPress action validates summary and blocked popups show an error', () => { + bootSupportModal(); + const wordpressButton = document.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ); + + wordpressButton.disabled = false; + wordpressButton.click(); + expect( openSpy ).not.toHaveBeenCalled(); + expect( document.getElementById( 'hcaptcha-support-summary' ).getAttribute( 'aria-invalid' ) ).toBe( 'true' ); + + document.getElementById( 'hcaptcha-support-summary' ).value = 'Need help'; + openSpy.mockReturnValueOnce( null ); + wordpressButton.click(); + + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( 'Your browser blocked the new tab. Please allow popups and try again.' ); + } ); + + test( 'document keydown is ignored while modal is hidden', () => { + bootSupportModal(); + const event = new KeyboardEvent( 'keydown', { key: 'Tab', bubbles: true } ); + const preventDefault = jest.spyOn( event, 'preventDefault' ); + + document.dispatchEvent( event ); + + expect( preventDefault ).not.toHaveBeenCalled(); + } ); + test( 'close button hides modal without a stored opener', () => { + bootSupportModal(); + const modal = document.getElementById( 'hcaptcha-support-modal' ); + + modal.hidden = false; + document.body.classList.add( 'hcaptcha-support-modal-open' ); + modal.querySelector( '[data-hcaptcha-support-close]' ).click(); + + expect( modal.hidden ).toBe( true ); + expect( document.body.classList.contains( 'hcaptcha-support-modal-open' ) ).toBe( false ); + } ); + + test( 'non-Tab keydown is ignored while modal is visible', () => { + bootSupportModal(); + const modal = document.getElementById( 'hcaptcha-support-modal' ); + const event = new KeyboardEvent( 'keydown', { key: 'ArrowDown', bubbles: true } ); + const preventDefault = jest.spyOn( event, 'preventDefault' ); + + modal.hidden = false; + document.dispatchEvent( event ); + + expect( preventDefault ).not.toHaveBeenCalled(); + } ); + + test( 'report building falls back when type and fields are missing', () => { + bootSupportModal( { + strings: { + report: {}, + }, + } ); + + document.querySelectorAll( 'input[name="hcaptcha-support-type"]' ).forEach( ( radio ) => { + radio.checked = false; + } ); + document.getElementById( 'hcaptcha-support-details' ).remove(); + document.getElementById( 'hcaptcha-support-summary' ).remove(); + document.getElementById( 'hcaptcha-support-area' ).remove(); + document.getElementById( 'hcaptcha-support-modal' ).dispatchEvent( new Event( 'change', { bubbles: true } ) ); + + expect( document.getElementById( 'hcaptcha-support-report' ).value ).toContain( '## ' ); + } ); + + test( 'help toggling keeps the current tip during close and handles missing help buttons', () => { + bootSupportModal(); + const githubAction = document.querySelector( '[data-hcaptcha-support-action="github"]' ); + const wordpressAction = document.querySelector( '[data-hcaptcha-support-action="wordpress"]' ); + const githubHelp = githubAction.querySelector( '.hcaptcha-support-action-help' ); + const wordpressHelp = wordpressAction.querySelector( '.hcaptcha-support-action-help' ); + + githubHelp.click(); + githubHelp.click(); + expect( githubAction.classList.contains( 'is-description-open' ) ).toBe( false ); + + githubAction.classList.add( 'is-description-open' ); + githubHelp.remove(); + wordpressHelp.click(); + + expect( githubAction.classList.contains( 'is-description-open' ) ).toBe( false ); + expect( wordpressAction.classList.contains( 'is-description-open' ) ).toBe( true ); + } ); + + test( 'copy and popup failures use empty fallback messages', async () => { + bootSupportModal( { + strings: { + report: {}, + }, + } ); + + Object.defineProperty( window.navigator, 'clipboard', { + configurable: true, + value: undefined, + } ); + document.querySelector( '[data-hcaptcha-support-copy]' ).click(); + await flushPromises(); + + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( '' ); + + document.getElementById( 'hcaptcha-support-summary' ).value = 'Need help'; + openSpy.mockReturnValueOnce( null ); + document.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ).click(); + + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( '' ); + } ); + test( 'missing summary field is handled while rebuilding the report', () => { + jest.resetModules(); + document.body.innerHTML = getDom().replace( '', '' ); + const config = { + ...defaultSupportModalObject, + strings: { + report: {}, + }, + }; + global.HCaptchaSupportModalObject = config; + window.HCaptchaSupportModalObject = config; + + require( '../../../assets/js/support-modal.js' ); + window.hCaptchaSupportModal(); + document.getElementById( 'hcaptcha-support-modal' ).dispatchEvent( new Event( 'change', { bubbles: true } ) ); + + expect( document.getElementById( 'hcaptcha-support-report' ).value ).toContain( '## ' ); + } ); + + test( 'summary validation can use an empty fallback message', () => { + bootSupportModal( { + strings: { + report: {}, + }, + } ); + document.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ).disabled = false; + + document.querySelector( '[data-hcaptcha-support-continue="wordpress"]' ).click(); + + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( '' ); + } ); + + test( 'copy success can use an empty fallback message', async () => { + bootSupportModal( { + strings: { + report: {}, + }, + } ); + + document.querySelector( '[data-hcaptcha-support-copy]' ).click(); + await flushPromises(); + + expect( document.getElementById( 'hcaptcha-support-status' ).textContent ).toBe( '' ); + } ); +} ); diff --git a/tests/js/assets-js-files/whats-new.test.js b/tests/js/assets-js-files/whats-new.test.js index e52371d9d..1bb471da2 100644 --- a/tests/js/assets-js-files/whats-new.test.js +++ b/tests/js/assets-js-files/whats-new.test.js @@ -9,7 +9,8 @@ const defaultWhatsNewObject = { ajaxUrl: 'https://test.test/wp-admin/admin-ajax.php', markShownAction: 'hcap_mark_whats_new_shown', markShownNonce: 'nonce-mark-shown', - whatsNewParam: 'whats_new', + renderPopupAction: 'hcap_render_whats_new_popup', + renderPopupNonce: 'nonce-render-popup', }; global.HCaptchaWhatsNewObject = { ...defaultWhatsNewObject }; @@ -20,10 +21,17 @@ function getDom( { withModal = true, display = 'none' } = {} ) { } return ` -
+
- 4.20.0 +
+ 4.20.0 + + +
@@ -32,6 +40,25 @@ function getDom( { withModal = true, display = 'none' } = {} ) { `.trim(); } +function getPopupResponseHtml( version = '4.13.0' ) { + return ` +
+
+ +
+ ${ version } + + +
+
+
+ lightbox-image +
+ `.trim(); +} function bootWhatsNew( domOptions = {}, objectOverrides = {} ) { jest.resetModules(); $( document ).off(); @@ -49,9 +76,22 @@ describe( 'whats-new.js', () => { beforeEach( () => { jest.clearAllMocks(); - postSpy = jest.spyOn( $, 'post' ).mockImplementation( () => { + postSpy = jest.spyOn( $, 'post' ).mockImplementation( ( options ) => { const deferred = $.Deferred(); + + if ( options.data.action === defaultWhatsNewObject.renderPopupAction ) { + deferred.resolve( { + success: true, + data: { + html: getPopupResponseHtml( options.data.version ), + }, + } ); + + return deferred.promise(); + } + deferred.resolve(); + return deferred.promise(); } ); fadeOutSpy = jest.spyOn( $.fn, 'fadeOut' ).mockImplementation( function( duration, callback ) { @@ -165,31 +205,125 @@ describe( 'whats-new.js', () => { expect( fadeInSpy ).toHaveBeenCalledWith( 200 ); } ); - test( 'markShown skips $.post when whatsNewParam is present in URL', () => { - const hasSpy = jest.spyOn( URLSearchParams.prototype, 'has' ).mockReturnValue( true ); + test( 'version toggle opens and closes the versions list', () => { bootWhatsNew(); - $( '#hcaptcha-whats-new-close' ).trigger( 'click' ); - expect( postSpy ).not.toHaveBeenCalled(); - hasSpy.mockRestore(); + const event = $.Event( 'click' ); + const preventDefaultSpy = jest.spyOn( event, 'preventDefault' ); + const $control = $( '.hcaptcha-whats-new-version-control' ); + const $toggle = $( '.hcaptcha-whats-new-version-toggle' ); + const $list = $( '.hcaptcha-whats-new-version-list' ); + + $toggle.trigger( event ); + + expect( preventDefaultSpy ).toHaveBeenCalled(); + expect( event.isPropagationStopped() ).toBe( true ); + expect( $control.hasClass( 'is-open' ) ).toBe( true ); + expect( $toggle.attr( 'aria-expanded' ) ).toBe( 'true' ); + expect( $list.attr( 'hidden' ) ).toBeUndefined(); + + $toggle.trigger( 'click' ); + + expect( $control.hasClass( 'is-open' ) ).toBe( false ); + expect( $toggle.attr( 'aria-expanded' ) ).toBe( 'false' ); + expect( $list.attr( 'hidden' ) ).toBe( 'hidden' ); + } ); + + test( 'document click closes the versions list', () => { + bootWhatsNew(); + const $control = $( '.hcaptcha-whats-new-version-control' ); + const $toggle = $( '.hcaptcha-whats-new-version-toggle' ); + const $list = $( '.hcaptcha-whats-new-version-list' ); + + $toggle.trigger( 'click' ); + $( document ).trigger( 'click' ); + + expect( $control.hasClass( 'is-open' ) ).toBe( false ); + expect( $toggle.attr( 'aria-expanded' ) ).toBe( 'false' ); + expect( $list.attr( 'hidden' ) ).toBe( 'hidden' ); } ); - test( 'button link click still opens link when whatsNewParam is in URL', () => { - const hasSpy = jest.spyOn( URLSearchParams.prototype, 'has' ).mockReturnValue( true ); + test( 'version link click loads and replaces popup via ajax', () => { bootWhatsNew(); - $( '.hcaptcha-whats-new-button a' ).trigger( 'click' ); + $( '.hcaptcha-whats-new-version-toggle' ).trigger( 'click' ); + $( '.hcaptcha-whats-new-version-link[data-version="4.13.0"]' ).trigger( 'click' ); + + expect( postSpy ).toHaveBeenCalledWith( + expect.objectContaining( { + url: defaultWhatsNewObject.ajaxUrl, + data: expect.objectContaining( { + action: defaultWhatsNewObject.renderPopupAction, + nonce: defaultWhatsNewObject.renderPopupNonce, + version: '4.13.0', + } ), + } ), + ); + expect( document.body.style.overflow ).toBe( 'hidden' ); + expect( $( '#hcaptcha-whats-new-modal' ).data( 'popup-version' ) ).toBe( '4.13.0' ); + expect( $( '#hcaptcha-whats-new-version' ).text() ).toBe( '4.13.0' ); + expect( $( '#hcaptcha-lightbox-modal' ).length ).toBe( 1 ); + } ); + test( 'version link without a version returns before AJAX', () => { + bootWhatsNew(); + $( '.hcaptcha-whats-new-version-list' ).append( '
  • Missing
  • ' ); + postSpy.mockClear(); + + $( '.hcaptcha-whats-new-version-link' ).last().trigger( 'click' ); + expect( postSpy ).not.toHaveBeenCalled(); - expect( openSpy ).toHaveBeenCalledWith( 'https://example.com', '_blank' ); - hasSpy.mockRestore(); } ); - test( 'markShown handles URLSearchParams error and falls through to $.post', () => { - const OriginalURLSearchParams = global.URLSearchParams; - global.URLSearchParams = function() { - throw new Error( 'not available' ); - }; + test( 'version load restores current popup when response cannot replace it', () => { + postSpy.mockImplementationOnce( () => { + const deferred = $.Deferred(); + deferred.resolve( { + success: true, + data: { + html: '
    ', + }, + } ); + + return deferred.promise(); + } ); bootWhatsNew(); - $( '#hcaptcha-whats-new-close' ).trigger( 'click' ); - expect( postSpy ).toHaveBeenCalled(); - global.URLSearchParams = OriginalURLSearchParams; + + $( '.hcaptcha-whats-new-version-link[data-version="4.13.0"]' ).trigger( 'click' ); + + expect( document.body.style.overflow ).toBe( 'hidden' ); + expect( $( '#hcaptcha-whats-new-modal' ).css( 'display' ) ).toBe( 'flex' ); + } ); + + test( 'version load restores current popup when request fails', () => { + postSpy.mockImplementationOnce( () => { + const deferred = $.Deferred(); + deferred.reject(); + + return deferred.promise(); + } ); + bootWhatsNew(); + + $( '.hcaptcha-whats-new-version-link[data-version="4.13.0"]' ).trigger( 'click' ); + + expect( document.body.style.overflow ).toBe( 'hidden' ); + expect( $( '#hcaptcha-whats-new-modal' ).css( 'display' ) ).toBe( 'flex' ); + } ); + + test( 'replacement swaps an existing lightbox modal', () => { + bootWhatsNew(); + document.body.insertAdjacentHTML( 'beforeend', '
    Old
    ' ); + + $( '.hcaptcha-whats-new-version-link[data-version="4.13.0"]' ).trigger( 'click' ); + + expect( $( '#hcaptcha-lightbox-modal img' ).length ).toBe( 1 ); + } ); + + test( 'document click inside version control does not close the dropdown', () => { + bootWhatsNew(); + const $control = $( '.hcaptcha-whats-new-version-control' ); + const $toggle = $( '.hcaptcha-whats-new-version-toggle' ); + + $toggle.trigger( 'click' ); + $control.trigger( 'click' ); + + expect( $control.hasClass( 'is-open' ) ).toBe( true ); } ); } ); diff --git a/tests/php/_bootstrap.php b/tests/php/_bootstrap.php new file mode 100644 index 000000000..0f01c0fc8 --- /dev/null +++ b/tests/php/_bootstrap.php @@ -0,0 +1,13 @@ +form_shown = false; - // Reset the enqueue state for the hcaptcha script so subsequent tests see it as not yet enqueued. + // Reset the enqueue state for the hcaptcha script so later tests see it as not yet enqueued. wp_dequeue_script( Main::HANDLE ); + WP_CLI::reset(); + parent::tearDown(); } @@ -157,6 +165,24 @@ public function test_init(): void { self::assertSame( Main::LOAD_PRIORITY, has_action( 'plugins_loaded', [ $hcaptcha, 'init_hooks' ] ) ); } + /** + * Test init_hooks() registers WP-CLI commands. + * + * @return void + */ + public function test_init_hooks_registers_wp_cli_command(): void { + WP_CLI::reset(); + + FunctionMocker::replace( '\HCaptcha\Helpers\Request::is_cli', true ); + + $subject = new Main(); + $subject->init_hooks(); + + do_action( 'cli_init' ); + + self::assertSame( Commands::class, WP_CLI::$commands['hcaptcha'] ?? null ); + } + /** * Test init() and init_hooks(). * @@ -516,9 +542,7 @@ public function test_print_inline_styles( $custom_themes, int $delay ): void { ], ] : []; - $load_msg = $delay >= 0 - ? 'If you see this message, hCaptcha failed to load due to site errors.' - : 'The hCaptcha loading is delayed until user interaction.'; + $load_msg = 'If you see this message, hCaptcha failed to load due to site errors.'; try { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode @@ -611,14 +635,23 @@ static function () { left: 0; right: 0; box-sizing: border-box; - color: #bf1722; + color: #bf1722; opacity: 0; } + .h-captcha.hcaptcha-api-delayed::after { + content: "The hCaptcha loading is delayed until user interaction with the form."; + color: #555; + } + .h-captcha:not(:has(iframe))::after { animation: hcap-msg-fade-in .3s ease forwards; animation-delay: 2s; } + + .h-captcha.hcaptcha-api-delayed:not(:has(iframe))::after { + animation-delay: 0s; + } .h-captcha:has(iframe)::after { animation: none; @@ -747,6 +780,50 @@ public function test_print_inline_styles_when_prohibited(): void { self::assertSame( '', ob_get_clean() ); } + /** + * Test print_inline_styles() when the delay API event is set. + * + * @return void + */ + public function test_print_inline_styles_with_delay_api_event(): void { + $delay_api_event_filter = static function () { + return 'hcap:load-api'; + }; + + add_filter( 'hcap_delay_api_event', $delay_api_event_filter ); + update_option( + 'hcaptcha_settings', + [ + 'delay' => 0, + ] + ); + + try { + $subject = new Main(); + + $subject->init_hooks(); + + ob_start(); + + $subject->print_inline_styles(); + + $styles = ob_get_clean(); + + self::assertStringContainsString( + '.h-captcha.hcaptcha-api-delayed::after{content:"The hCaptcha loading is delayed until user interaction with the form.";color:#555}', + $styles + ); + self::assertStringContainsString( + '.h-captcha.hcaptcha-api-delayed:not(:has(iframe))::after{animation-delay:0s}', + $styles + ); + self::assertStringContainsString( 'If you see this message', $styles ); + self::assertStringNotContainsString( 'visibility:hidden', $styles ); + } finally { + remove_filter( 'hcap_delay_api_event', $delay_api_event_filter ); + } + } + /** * Test login_head(). * @@ -1145,6 +1222,24 @@ public function test_print_footer_script_when_blocked_by_filter(): void { self::assertFalse( strpos( $scripts, 'api.js' ) ); } + /** + * Test print_footer_scripts() without settings. + * + * @return void + */ + public function test_print_footer_scripts_without_settings(): void { + $subject = new Main(); + + $subject->form_shown = true; + + ob_start(); + $subject->print_footer_scripts(); + $scripts = ob_get_clean(); + + self::assertSame( '', $scripts ); + self::assertFalse( wp_script_is( Main::HANDLE ) ); + } + /** * Test print_footer_scripts() when the delay API selector is set. * @@ -1180,6 +1275,49 @@ static function () use ( $selector ) { self::assertNotFalse( strpos( $scripts, 'hooks' ) ); } + /** + * Test print_footer_scripts() when the delay API event is set. + * + * @return void + */ + public function test_print_footer_scripts_with_delay_api_event(): void { + $delay_api_event = 'hcap:load-api'; + $delay_api_event_filter = static function () use ( $delay_api_event ) { + return $delay_api_event; + }; + + add_filter( 'hcap_delay_api_event', $delay_api_event_filter ); + + try { + $hcaptcha = hcaptcha(); + + $hcaptcha->form_shown = true; + + $hcaptcha->init_hooks(); + + ob_start(); + do_action( 'wp_print_footer_scripts' ); + $scripts = ob_get_clean(); + + self::assertNotFalse( strpos( $scripts, 'var HCaptchaMainObject = ' ) ); + self::assertNotFalse( strpos( $scripts, 'const delayApiEvent="hcap:load-api";' ) ); + self::assertNotFalse( + strpos( + $scripts, + "document.addEventListener('hCaptchaBeforeAPI',function(){document.addEventListener(delayApiEvent,load)})" + ) + ); + self::assertNotFalse( strpos( $scripts, 'api.js' ) ); + self::assertFalse( strpos( $scripts, 'setTimeout' ) ); + self::assertFalse( strpos( $scripts, 'touchstart' ) ); + self::assertFalse( strpos( $scripts, 'mouseenter' ) ); + self::assertFalse( strpos( $scripts, 'keydown' ) ); + self::assertFalse( strpos( $scripts, 'scrollHandler' ) ); + } finally { + remove_filter( 'hcap_delay_api_event', $delay_api_event_filter ); + } + } + /** * Data provider for test_print_footer_scripts(). * @@ -1419,6 +1557,51 @@ public function test_allowlist_country( array $allowlisted_countries, string $co } } + /** + * Test denylist by country without a client IP. + * + * @return void + */ + public function test_denylist_country_without_client_ip(): void { + update_option( 'hcaptcha_settings', [ 'blacklisted_countries' => [ 'US' ] ] ); + + $subject = new Main(); + + $subject->init_hooks(); + + self::assertFalse( $subject->denylist_ip( false, false ) ); + } + + /** + * Test denylist by country without a MaxMind DB path. + * + * @return void + */ + public function test_denylist_country_without_maxmind_db_path(): void { + update_option( 'hcaptcha_settings', [ 'blacklisted_countries' => [ 'US' ] ] ); + + FunctionMocker::replace( 'is_readable', false ); + + $maxmind_db_path_filter = static function () { + return __DIR__ . '/missing-country.mmdb'; + }; + + add_filter( + 'hcap_maxmind_db_path', + $maxmind_db_path_filter + ); + + try { + $subject = new Main(); + + $subject->init_hooks(); + + self::assertFalse( $subject->denylist_ip( false, '203.0.113.30' ) ); + } finally { + remove_filter( 'hcap_maxmind_db_path', $maxmind_db_path_filter ); + } + } + /** * Data provider for test_allowlist_country(). * @@ -1455,6 +1638,78 @@ public function test_print_footer_scripts_when_form_NOT_shown(): void { self::assertFalse( strpos( $scripts, 'api.js' ) ); } + /** + * Test register_recurring_actions() without Action Scheduler. + * + * @return void + */ + public function test_register_recurring_actions_without_action_scheduler(): void { + FunctionMocker::replace( + 'function_exists', + static function ( string $function_name ): bool { + if ( 'as_schedule_recurring_action' === $function_name ) { + return false; + } + + return function_exists( $function_name ); + } + ); + + $subject = new Main(); + $subject->register_recurring_actions(); + + self::assertNull( $subject->settings() ); + } + + /** + * Test register_recurring_actions(). + * + * @return void + * @noinspection PhpArrayIsAlwaysEmptyInspection + */ + public function test_register_recurring_actions(): void { + require_once HCAPTCHA_PATH . '/vendor/woocommerce/action-scheduler/action-scheduler.php'; + + if ( + ! function_exists( 'as_schedule_recurring_action' ) || + ! function_exists( 'as_unschedule_all_actions' ) || + ! function_exists( 'as_next_scheduled_action' ) + ) { + self::markTestSkipped( 'Action Scheduler is not available.' ); + } + + as_unschedule_all_actions( MaxMindDb::UPDATE_ACTION, [], 'hcaptcha' ); + as_unschedule_all_actions( Events::CLEANUP_ACTION, [], 'hcaptcha' ); + + try { + update_option( 'hcaptcha_settings', [ 'statistics' => [ 'on' ] ] ); + + $subject = new Main(); + $subject->init_hooks(); + $subject->register_recurring_actions(); + + $maxmind_scheduled = as_next_scheduled_action( MaxMindDb::UPDATE_ACTION, [], 'hcaptcha' ); + $cleanup_scheduled = as_next_scheduled_action( Events::CLEANUP_ACTION, [], 'hcaptcha' ); + + self::assertIsInt( $maxmind_scheduled ); + self::assertGreaterThan( time(), $maxmind_scheduled ); + self::assertIsInt( $cleanup_scheduled ); + self::assertGreaterThan( time(), $cleanup_scheduled ); + + update_option( 'hcaptcha_settings', [ 'statistics' => [] ] ); + + $subject = new Main(); + $subject->init_hooks(); + $subject->register_recurring_actions(); + + self::assertIsInt( as_next_scheduled_action( MaxMindDb::UPDATE_ACTION, [], 'hcaptcha' ) ); + self::assertFalse( as_next_scheduled_action( Events::CLEANUP_ACTION, [], 'hcaptcha' ) ); + } finally { + as_unschedule_all_actions( MaxMindDb::UPDATE_ACTION, [], 'hcaptcha' ); + as_unschedule_all_actions( Events::CLEANUP_ACTION, [], 'hcaptcha' ); + } + } + /** * Test load_modules(). * @@ -1493,6 +1748,7 @@ static function () use ( &$activate ) { DisposableEmail::class, Events::class, Privacy::class, + SupportModal::class, WhatsNew::class, Abilities::class, ]; diff --git a/tests/php/integration/ACFE/FormTest.php b/tests/php/integration/ACFE/FormTest.php index b716bcbe0..37b4ab13c 100644 --- a/tests/php/integration/ACFE/FormTest.php +++ b/tests/php/integration/ACFE/FormTest.php @@ -210,9 +210,9 @@ public function test_verify( bool $result, $expected ): void { $form_id = 5; $field = [ 'required' => true ]; - $_POST['_acf_form'] = 'acf-form'; - $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = 'encoded-hash'; - $_POST['acf'] = []; + $_POST['_acf_form'] = 'acf-form'; + $this->prepare_widget_id( $form_id ); + $_POST['acf'] = []; $this->mock_acf_form( $form_id ); @@ -259,6 +259,36 @@ public function test_verify_when_NOT_required(): void { self::assertFalse( $subject->verify( false, $value, $field, $input ) ); } + /** + * Test verify direct POST without previous AJAX validation. + * + * @param bool $result Request result. + * @param bool|string $expected Expected. + * + * @return void + * @dataProvider dp_test_verify + * @throws ReflectionException ReflectionException. + */ + public function test_verify_direct_post_without_ajax( bool $result, $expected ): void { + $valid = ! $expected; + $value = 'some hcaptcha response'; + $input = 'some_input_name'; + $form_id = 5; + $field = [ 'required' => true ]; + + $_POST['_acf_form'] = 'acf-form'; + $this->prepare_widget_id( $form_id ); + $_POST['acf'] = []; + + $this->mock_acf_form( $form_id ); + $this->prepare_verify_request( $value, $result ); + + $subject = new Form(); + + self::assertSame( $expected, $subject->verify( $valid, $value, $field, $input ) ); + self::assertSame( $form_id, $this->get_protected_property( $subject, 'form_id' ) ); + } + /** * Test verify on ajax. * @@ -270,6 +300,12 @@ public function test_verify_ajax(): void { $input = 'some_input_name'; $field = [ 'required' => true ]; + $form_id = 5; + + $_POST['_acf_form'] = 'acf-form'; + $this->prepare_widget_id( $form_id ); + + $this->mock_acf_form( $form_id ); $this->prepare_verify_request( $value, false ); add_filter( 'wp_doing_ajax', '__return_true' ); @@ -277,9 +313,52 @@ public function test_verify_ajax(): void { $subject = new Form(); self::assertSame( 'The hCaptcha is invalid.', $subject->verify( true, $value, $field, $input ) ); + + $this->prepare_widget_id( $form_id ); + self::assertSame( 'The hCaptcha is invalid.', $subject->verify( false, $value, $field, $input ) ); - self::assertSame( 0, $this->get_protected_property( $subject, 'form_id' ) ); + self::assertSame( $form_id, $this->get_protected_property( $subject, 'form_id' ) ); + } + + /** + * Test verify on ajax when widget id is missing. + * + * @return void + */ + public function test_verify_ajax_missing_widget_id(): void { + $value = 'some hcaptcha response'; + $input = 'some_input_name'; + $field = [ 'required' => true ]; + $form_id = 5; + + $_POST['_acf_form'] = 'acf-form'; + + $this->mock_acf_form( $form_id ); + $this->prepare_verify_request( $value ); + + add_filter( 'wp_doing_ajax', '__return_true' ); + + $subject = new Form(); + + self::assertSame( 'Bad hCaptcha signature!', $subject->verify( true, $value, $field, $input ) ); + + remove_filter( 'wp_doing_ajax', '__return_true' ); + } + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * + * @return void + */ + private function prepare_widget_id( int $form_id ): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'acf-extended-pro/acf-extended.php', 'acf-extended/acf-extended.php' ], + 'form_id' => $form_id, + ] + ); } /** diff --git a/tests/php/integration/Abilities/AbilitiesTest.php b/tests/php/integration/Abilities/AbilitiesTest.php index 3a63524db..1dae8a761 100644 --- a/tests/php/integration/Abilities/AbilitiesTest.php +++ b/tests/php/integration/Abilities/AbilitiesTest.php @@ -722,35 +722,6 @@ public function test_window_to_seconds(): void { $this->set_method_accessibility( $subject, 'window_to_seconds', false ); } - /** - * Test calculate_attack_likelihood(). - * - * @return void - * @throws ReflectionException Reflection exception. - */ - public function test_calculate_attack_likelihood(): void { - $subject = new Abilities(); - - $method = $this->set_method_accessibility( $subject, 'calculate_attack_likelihood' ); - - // When there is insufficient sample size, always return low. - self::assertSame( 'low', $method->invoke( $subject, 0, 0, 0.0 ) ); - self::assertSame( 'low', $method->invoke( $subject, 19, 19, 1.0 ) ); - - // High-likelihood threshold (inclusive). - self::assertSame( 'high', $method->invoke( $subject, 20, 10, 0.50 ) ); - - // Medium likelihood threshold (inclusive). - self::assertSame( 'medium', $method->invoke( $subject, 20, 5, 0.20 ) ); - self::assertSame( 'medium', $method->invoke( $subject, 20, 9, 0.60 ) ); - - // Fallback to low if thresholds are not met. - self::assertSame( 'low', $method->invoke( $subject, 20, 4, 0.90 ) ); - self::assertSame( 'low', $method->invoke( $subject, 20, 5, 0.19 ) ); - - $this->set_method_accessibility( $subject, 'calculate_attack_likelihood', false ); - } - /** * Test calculate_confidence(). * @@ -813,9 +784,8 @@ public function test_query_threats_snapshot_returns_empty_on_null_total(): void 'fail_rate' => 0.0, ], 'signals' => [ - 'attack_likelihood' => 'low', - 'confidence' => 'low', - 'top_vectors' => [], + 'confidence' => 'low', + 'top_vectors' => [], ], 'breakdown' => [ 'errors' => [], diff --git a/tests/php/integration/Admin/AdminNoticesTest.php b/tests/php/integration/Admin/AdminNoticesTest.php index 37e492aa9..db5bfae9a 100644 --- a/tests/php/integration/Admin/AdminNoticesTest.php +++ b/tests/php/integration/Admin/AdminNoticesTest.php @@ -11,7 +11,10 @@ use HCaptcha\Migrations\Migrations; use HCaptcha\Settings\AntiSpamPage; use HCaptcha\Settings\PluginSettingsBase; +use HCaptcha\Settings\Settings; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use Mockery; +use ReflectionException; /** * Test AdminNotices. @@ -27,7 +30,7 @@ class AdminNoticesTest extends HCaptchaWPTestCase { * @return void */ public function test_show_trusted_address_headers_notice(): void { - $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); wp_set_current_user( $user_id ); @@ -52,13 +55,61 @@ public function test_show_trusted_address_headers_notice(): void { self::assertStringNotContainsString( 'is-dismissible', $output ); } + /** + * Test show_trusted_address_headers_notice() without settings. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_show_trusted_address_headers_notice_without_settings(): void { + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + + wp_set_current_user( $user_id ); + + update_option( + PluginSettingsBase::OPTION_NAME, + [ + Migrations::REVIEW_TRUSTED_ADDRESS_HEADERS_OPTION => 'on', + ] + ); + + $main = hcaptcha(); + $settings = $this->get_protected_property( $main, 'settings' ); + $settings_mock = Mockery::mock( Settings::class ); + + $settings_mock->shouldReceive( 'get_raw_settings' )->once()->andReturnUsing( + function () use ( $main ): array { + $this->set_protected_property( $main, 'settings', null ); + + return [ + Migrations::REVIEW_TRUSTED_ADDRESS_HEADERS_OPTION => 'on', + ]; + } + ); + + $this->set_protected_property( $main, 'settings', $settings_mock ); + + try { + ob_start(); + ( new AdminNotices() )->show_trusted_address_headers_notice(); + $output = ob_get_clean(); + } finally { + $this->set_protected_property( $main, 'settings', $settings ); + } + + self::assertStringContainsString( + admin_url( 'admin.php?page=hcaptcha-antispampage#trusted_address_headers' ), + $output + ); + } + /** * Test show_trusted_address_headers_notice() without a review flag. * * @return void */ public function test_show_trusted_address_headers_notice_without_flag(): void { - $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); wp_set_current_user( $user_id ); diff --git a/tests/php/integration/Admin/Events/EventsTest.php b/tests/php/integration/Admin/Events/EventsTest.php index e8283f606..aab21e53d 100644 --- a/tests/php/integration/Admin/Events/EventsTest.php +++ b/tests/php/integration/Admin/Events/EventsTest.php @@ -12,7 +12,9 @@ namespace HCaptcha\Tests\Integration\Admin\Events; +use Exception; use HCaptcha\Admin\Events\Events; +use HCaptcha\Migrations\Migrations; use HCaptcha\Settings\General; use HCaptcha\Settings\PluginSettingsBase; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; @@ -48,6 +50,10 @@ public function tearDown(): void { unset( $_SERVER['HTTP_USER_AGENT'], $_SERVER['HTTP_TRUE_CLIENT_IP'] ); $this->drop_table(); + Events::create_table( true ); + + delete_option( Migrations::MIGRATED_VERSIONS_OPTION_NAME ); + wp_cache_delete( Migrations::MIGRATED_VERSIONS_OPTION_NAME, 'options' ); parent::tearDown(); } @@ -348,14 +354,14 @@ public function test_create_table(): void { status VARCHAR(20) NOT NULL DEFAULT 'active', trashed_at_gmt DATETIME NULL, PRIMARY KEY (id), - KEY source (source), + KEY source (source(191)), KEY form_id (form_id), - KEY hcaptcha_id (source, form_id), + KEY hcaptcha_id (source(191), form_id), KEY ip (ip), KEY uuid (uuid), KEY date_gmt (date_gmt), KEY status_date_gmt (status, date_gmt), - KEY status_source_form (status, source, form_id) + KEY status_source_form (status, source(191), form_id) ) $charset_collate"; $filter = static function ( $queries ) use ( &$actual_query ) { $actual_query = $queries; @@ -371,7 +377,15 @@ public function test_create_table(): void { remove_filter( 'dbdelta_queries', $filter ); - $this->assertSame( [ $expected_query ], array_values( $actual_query ) ); + $expected_query = str_replace( "\r\n", "\n", $expected_query ); + $actual_query = array_map( + static function ( string $query ): string { + return str_replace( "\r\n", "\n", $query ); + }, + array_values( $actual_query ) + ); + + $this->assertSame( [ $expected_query ], $actual_query ); self::assertTrue( Events::table_exists() ); } @@ -535,6 +549,295 @@ public function test_get_where_date_gmt(): void { self::assertEquals( '1=1', $actual ); } + /** + * Test save_event() returns when an event was already saved. + * + * @return void + */ + public function test_save_event_returns_when_already_saved(): void { + $subject = new Events(); + + $subject->save_event( 'empty', [], (object) [] ); + + self::assertSame( 'success', $subject->save_event( 'success', [], (object) [] ) ); + } + + /** + * Test save_event() anonymizes collected IP and user agent. + * + * @return void + */ + public function test_save_event_anonymizes_ip_and_user_agent(): void { + global $wpdb; + + $user_agent = 'anonymous user agent'; + $ip = '2.2.2.2'; + + $_SERVER['HTTP_USER_AGENT'] = $user_agent; + $_SERVER['HTTP_TRUE_CLIENT_IP'] = $ip; + + update_option( + 'hcaptcha_settings', + [ + 'collect_ua' => [ 'on' ], + 'collect_ip' => [ 'on' ], + 'anonymous' => [ 'on' ], + 'trusted_address_headers' => [ 'HTTP_TRUE_CLIENT_IP' ], + ] + ); + hcaptcha()->init_hooks(); + + $this->drop_table(); + Events::create_table(); + + ( new Events() )->save_event( null, [], (object) [] ); + + $table_name = Events::TABLE_NAME; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $event = $wpdb->get_row( "SELECT * FROM $wpdb->prefix$table_name ORDER BY id DESC LIMIT 1" ); + + self::assertSame( wp_hash( $ip ), $event->ip ); + self::assertSame( wp_hash( $user_agent ), $event->user_agent ); + } + + /** + * Test trash queries return empty when legacy schema is marked as not ready. + * + * @return void + */ + public function test_trash_queries_return_empty_when_schema_is_not_ready(): void { + $this->drop_table(); + Events::create_table(); + $this->set_trash_schema_ready( false ); + + self::assertSame( + [ + 'items' => [], + 'total' => 0, + ], + Events::get_events( [ 'status' => Events::STATUS_TRASH ] ) + ); + self::assertSame( + [ + 'items' => [], + 'total' => 0, + 'served' => [], + ], + Events::get_forms( [ 'status' => Events::STATUS_TRASH ] ) + ); + } + + /** + * Test get_events() handles a failed SQL query. + * + * @return void + */ + public function test_get_events_returns_empty_on_query_failure(): void { + $this->drop_table(); + Events::create_table(); + + self::assertSame( + [ + 'items' => [], + 'total' => 0, + ], + Events::get_events( [ 'columns' => [ 'missing_column' ] ] ) + ); + } + + /** + * Test get_status_counts() with a legacy schema. + * + * @return void + */ + public function test_get_status_counts_without_trash_schema(): void { + $this->drop_table(); + Events::create_table(); + $this->set_trash_schema_ready( false ); + $this->insert_event(); + $this->insert_event( [ 'status' => Events::STATUS_TRASH ] ); + + self::assertSame( + [ + Events::STATUS_ACTIVE => 2, + Events::STATUS_TRASH => 0, + ], + Events::get_status_counts() + ); + } + + /** + * Test get_status_counts() with a trash schema. + * + * @return void + */ + public function test_get_status_counts_with_trash_schema(): void { + $this->drop_table(); + Events::create_table(); + $this->set_trash_schema_ready( true ); + $this->insert_event(); + $this->insert_event( [ 'status' => Events::STATUS_TRASH ] ); + + self::assertSame( + [ + Events::STATUS_ACTIVE => 1, + Events::STATUS_TRASH => 1, + ], + Events::get_status_counts() + ); + } + + /** + * Test create_table() force path when the created marker is absent. + * + * @return void + */ + public function test_create_table_force_without_created_marker(): void { + $this->drop_table(); + delete_option( PluginSettingsBase::OPTION_NAME ); + + Events::create_table( true ); + + self::assertTrue( $this->database_table_exists() ); + self::assertTrue( Events::table_exists() ); + } + + /** + * Test cleanup_trash() returns when statistics are on but the table is missing. + * + * @return void + */ + public function test_cleanup_trash_returns_when_table_is_missing(): void { + global $wpdb; + + update_option( 'hcaptcha_settings', [ 'statistics' => [ 'on' ] ] ); + hcaptcha()->init_hooks(); + $this->set_trash_schema_ready( true ); + $this->drop_table(); + + $wpdb->last_query = ''; + + Events::cleanup_trash(); + + self::assertStringNotContainsString( 'DELETE FROM', $wpdb->last_query ); + } + + /** + * Test cleanup_trash() deletes expired trashed events. + * + * @return void + */ + public function test_cleanup_trash_deletes_expired_trash(): void { + global $wpdb; + + update_option( 'hcaptcha_settings', [ 'statistics' => [ 'on' ] ] ); + hcaptcha()->init_hooks(); + $this->set_trash_schema_ready( true ); + $this->drop_table(); + Events::create_table(); + + $old_date = gmdate( 'Y-m-d H:i:s', time() - ( Events::TRASH_RETENTION_DAYS + 1 ) * DAY_IN_SECONDS ); + $new_date = gmdate( 'Y-m-d H:i:s' ); + + $this->insert_event(); + $this->insert_event( + [ + 'status' => Events::STATUS_TRASH, + 'trashed_at_gmt' => $old_date, + ] + ); + $this->insert_event( + [ + 'status' => Events::STATUS_TRASH, + 'trashed_at_gmt' => $new_date, + ] + ); + + Events::cleanup_trash(); + + $table_name = $wpdb->prefix . Events::TABLE_NAME; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $trash_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table_name WHERE status = 'trash'" ); + + self::assertSame( 1, $trash_count ); + } + + /** + * Test get_where_date_gmt_nested() without dates. + * + * @return void + */ + public function test_get_where_date_gmt_nested_without_dates(): void { + self::assertSame( '1=1', Events::get_where_date_gmt_nested( [ 'dates' => [] ] ) ); + } + + /** + * Test get_default_dates() handles DateTime modification errors. + * + * @return void + */ + public function test_get_default_dates_handles_modify_exception(): void { + FunctionMocker::replace( + 'date_create_immutable', + static function () { + return new class() { + /** + * Modify date. + * + * @return void + * @throws Exception Date exception. + * @noinspection ThrowRawExceptionInspection + */ + public function modify(): void { + throw new Exception( 'Bad date.' ); + } + + // phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- Testing DateTimeImmutable-compatible API. + /** + * Set time. + * + * @return self + * @noinspection PhpUnused + */ + public function setTime(): self { + // phpcs:ignore PHPCompatibility.FunctionDeclarations.NewClosure.ThisFoundInStatic -- False positive inside anonymous class. + return $this; + } + // phpcs:enable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + + /** + * Format date. + * + * @return string + */ + public function format(): string { + return '2026-01-02'; + } + }; + } + ); + + self::assertSame( [ '2026-01-02', '2026-01-02' ], Events::get_default_dates() ); + } + + /** + * Test get_events() with legacy schema and invalid status. + * + * @return void + */ + public function test_get_events_with_legacy_schema_and_invalid_status(): void { + $this->drop_table(); + Events::create_table(); + $this->set_trash_schema_ready( false ); + $this->insert_event(); + + $events = Events::get_events( [ 'status' => 'bad-status' ] ); + + self::assertSame( 1, $events['total'] ); + } + /** * Drop the table. * @@ -578,6 +881,49 @@ private function mark_events_table_created(): void { update_option( PluginSettingsBase::OPTION_NAME, $settings ); } + /** + * Set a trash schema readiness marker. + * + * @param bool $ready Whether trash schema is ready. + * + * @return void + */ + private function set_trash_schema_ready( bool $ready ): void { + $versions = [ '5.0.0' => $ready ? time() : -1 ]; + + update_option( Migrations::MIGRATED_VERSIONS_OPTION_NAME, $versions ); + wp_cache_set( Migrations::MIGRATED_VERSIONS_OPTION_NAME, $versions, 'options' ); + } + + /** + * Insert an event row. + * + * @param array $data Event data. + * + * @return void + */ + private function insert_event( array $data = [] ): void { + global $wpdb; + + $data = array_merge( + [ + 'source' => '[]', + 'form_id' => '0', + 'ip' => '', + 'user_agent' => '', + 'uuid' => '', + 'error_codes' => '[]', + 'date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'status' => Events::STATUS_ACTIVE, + 'trashed_at_gmt' => null, + ], + $data + ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( $wpdb->prefix . Events::TABLE_NAME, $data ); + } + /** * Clear the events table created marker. * diff --git a/tests/php/integration/Admin/MaxMindDbTest.php b/tests/php/integration/Admin/MaxMindDbTest.php new file mode 100644 index 000000000..c2dbbee5e --- /dev/null +++ b/tests/php/integration/Admin/MaxMindDbTest.php @@ -0,0 +1,819 @@ +temp_files as $file ) { + if ( is_file( $file ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $file ); + } + } + + foreach ( array_reverse( $this->temp_dirs ) as $dir ) { + $this->remove_dir( $dir ); + } + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_filesystem = null; + + parent::tearDown(); + } + + /** + * Test activate() and deactivate() without Action Scheduler. + * + * @return void + */ + public function test_activate_and_deactivate_without_action_scheduler(): void { + FunctionMocker::replace( 'function_exists', false ); + + $subject = new MaxMindDb(); + + $subject->activate( '' ); + $subject->deactivate(); + + self::assertTrue( true ); + } + + /** + * Test schedule_update() and unschedule_update() with Action Scheduler available. + * + * @return void + * @throws ReflectionException Reflection exception. + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_schedule_and_unschedule_with_action_scheduler(): void { + require_once HCAPTCHA_PATH . '/vendor/woocommerce/action-scheduler/action-scheduler.php'; + + if ( + ! function_exists( 'as_schedule_recurring_action' ) || + ! function_exists( 'as_unschedule_all_actions' ) || + ! function_exists( 'as_next_scheduled_action' ) + ) { + self::markTestSkipped( 'Action Scheduler is not available.' ); + } + + as_unschedule_all_actions( MaxMindDb::UPDATE_ACTION, [], 'hcaptcha' ); + + $subject = new MaxMindDb(); + $schedule_method = $this->set_method_accessibility( $subject, 'schedule_update' ); + $unschedule_method = $this->set_method_accessibility( $subject, 'unschedule_update' ); + + self::assertFalse( as_next_scheduled_action( MaxMindDb::UPDATE_ACTION, [], 'hcaptcha' ) ); + + $schedule_method->invoke( $subject ); + + $scheduled = as_next_scheduled_action( MaxMindDb::UPDATE_ACTION, [], 'hcaptcha' ); + + self::assertIsInt( $scheduled ); + self::assertGreaterThan( time(), $scheduled ); + + $unschedule_method->invoke( $subject ); + + self::assertFalse( as_next_scheduled_action( MaxMindDb::UPDATE_ACTION, [], 'hcaptcha' ) ); + } + + /** + * Test load_db() when a filtered DB path already exists. + * + * @return void + */ + public function test_load_db_uses_existing_filtered_path(): void { + $called = false; + $db = $this->create_temp_file( 'GeoLite2-Country.mmdb', 'db' ); + + add_filter( + 'hcap_maxmind_db_path', + static function () use ( $db ) { + return $db; + } + ); + add_filter( + 'pre_http_request', + static function () use ( &$called ) { + $called = true; + + return new WP_Error( 'unexpected_download' ); + } + ); + + ( new MaxMindDb() )->load_db( 'license-key' ); + + self::assertFalse( $called ); + } + + /** + * Test load_db() when download_url() returns WP_Error. + * + * @return void + */ + public function test_load_db_returns_on_download_error(): void { + $restore_default_db = $this->move_default_db_aside(); + + add_filter( + 'pre_http_request', + static function () { + return new WP_Error( 'download_error' ); + } + ); + + try { + ( new MaxMindDb() )->load_db( 'license key' ); + } finally { + $restore_default_db(); + } + + self::assertTrue( true ); + } + + /** + * Test load_db() downloads an archive when no DB exists. + * + * @return void + */ + public function test_load_db_downloads_archive_when_default_db_is_absent(): void { + $archive_path = $this->create_maxmind_archive(); + $target_path = $this->create_temp_dir( 'download-target' ) . '/GeoLite2-Country.mmdb'; + $restore_default_db = $this->move_default_db_aside(); + $downloaded = false; + + $this->mock_successful_wp_filesystem(); + + add_filter( + 'hcap_maxmind_db_path', + static function () use ( $target_path ): string { + return $target_path; + } + ); + add_filter( + 'pre_http_request', + static function ( $preempt, array $args ) use ( $archive_path, &$downloaded ) { + $downloaded = true; + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy + copy( $archive_path, $args['filename'] ); + + return [ + 'headers' => [ + 'content-disposition' => 'attachment; filename=country.tar', + ], + 'body' => '', + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + }, + 10, + 2 + ); + + try { + ( new MaxMindDb() )->load_db( 'license key' ); + } finally { + $restore_default_db(); + } + + self::assertTrue( $downloaded ); + } + + /** + * Test update_db() without a stored key. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_update_db_returns_without_key(): void { + $main = hcaptcha(); + $settings = $this->get_protected_property( $main, 'settings' ); + + $this->set_protected_property( $main, 'settings', null ); + + try { + ( new MaxMindDb() )->update_db(); + } finally { + $this->set_protected_property( $main, 'settings', $settings ); + } + + self::assertTrue( true ); + } + + /** + * Test update_db() with a stored key. + * + * @return void + */ + public function test_update_db_downloads_with_key(): void { + update_option( 'hcaptcha_settings', [ 'maxmind_key' => 'license-key' ] ); + hcaptcha()->init_hooks(); + + add_filter( + 'pre_http_request', + static function () { + return new WP_Error( 'download_error' ); + } + ); + + ( new MaxMindDb() )->update_db(); + + self::assertTrue( true ); + } + + /** + * Test default and filtered DB paths. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_default_filtered_existing_and_target_paths(): void { + $subject = new MaxMindDb(); + $get_default_paths = $this->set_method_accessibility( $subject, 'get_default_db_paths' ); + $get_existing_db_path = $this->set_method_accessibility( $subject, 'get_existing_db_path' ); + $get_target_db_path = $this->set_method_accessibility( $subject, 'get_target_db_path' ); + $default_path = WP_CONTENT_DIR . '/uploads/hcaptcha/GeoLite2-Country.mmdb'; + $filtered_path = $this->create_temp_file( 'filtered.mmdb', 'filtered' ); + + self::assertSame( [ $default_path ], $get_default_paths->invoke( $subject ) ); + self::assertSame( $default_path, $get_target_db_path->invoke( $subject ) ); + + add_filter( + 'hcap_maxmind_db_path', + static function () use ( $filtered_path ) { + return $filtered_path; + } + ); + + self::assertSame( $filtered_path, $get_existing_db_path->invoke( $subject ) ); + self::assertSame( $filtered_path, $get_target_db_path->invoke( $subject ) ); + + remove_all_filters( 'hcap_maxmind_db_path' ); + remove_all_filters( 'pre_http_request' ); + + if ( ! is_file( $default_path ) ) { + wp_mkdir_p( dirname( $default_path ) ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $default_path, 'default' ); + $this->temp_files[] = $default_path; + } + + self::assertSame( $default_path, $get_existing_db_path->invoke( $subject ) ); + } + + /** + * Test get_existing_db_path() when no path is readable. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_existing_db_path_returns_empty_without_readable_paths(): void { + $subject = new MaxMindDb(); + $get_existing_db_path = $this->set_method_accessibility( $subject, 'get_existing_db_path' ); + $restore_default_db = $this->move_default_db_aside(); + + try { + self::assertSame( '', $get_existing_db_path->invoke( $subject ) ); + } finally { + $restore_default_db(); + } + } + + /** + * Test extract_db_from_archive(). + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_extract_db_from_archive_moves_database(): void { + $subject = new MaxMindDb(); + $method = $this->set_method_accessibility( $subject, 'extract_db_from_archive' ); + $archive_path = $this->create_maxmind_archive(); + $target_dir = $this->create_temp_dir( 'target' ); + $target_path = $target_dir . '/nested/GeoLite2-Country.mmdb'; + + $this->mock_successful_wp_filesystem(); + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Suppress PharData warnings in tests. + set_error_handler( + static function (): bool { + return true; + } + ); + + try { + $method->invoke( $subject, $archive_path, $target_path ); + } finally { + restore_error_handler(); + } + + self::assertFileDoesNotExist( $archive_path ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local test fixture. + self::assertSame( 'country-db', file_get_contents( $target_path ) ); + } + + /** + * Test extract_db_from_archive() when the filesystem cannot initialize. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_extract_db_from_archive_returns_when_filesystem_is_unavailable(): void { + $subject = new MaxMindDb(); + $method = $this->set_method_accessibility( $subject, 'extract_db_from_archive' ); + $archive_path = $this->create_temp_file( 'archive.tar', 'not-used' ); + + FunctionMocker::replace( 'HCaptcha\Admin\WP_Filesystem', false ); + + $method->invoke( $subject, $archive_path, $this->create_temp_dir( 'target' ) . '/GeoLite2-Country.mmdb' ); + + self::assertFileExists( $archive_path ); + } + + /** + * Test extract_db_from_archive() handles invalid archives. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_extract_db_from_archive_returns_on_invalid_archive(): void { + $subject = new MaxMindDb(); + $method = $this->set_method_accessibility( $subject, 'extract_db_from_archive' ); + $archive_path = $this->create_temp_file( 'invalid.tar', 'invalid' ); + + $this->mock_successful_wp_filesystem(); + + $method->invoke( $subject, $archive_path, $this->create_temp_dir( 'target' ) . '/GeoLite2-Country.mmdb' ); + + self::assertFileDoesNotExist( $archive_path ); + } + + /** + * Test extract_db_from_archive() returns when the extracted DB is missing. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_extract_db_from_archive_returns_when_database_file_is_missing(): void { + $subject = new MaxMindDb(); + $method = $this->set_method_accessibility( $subject, 'extract_db_from_archive' ); + $archive_path = $this->create_maxmind_archive( false ); + $target_path = $this->create_temp_dir( 'target' ) . '/GeoLite2-Country.mmdb'; + + $this->mock_successful_wp_filesystem(); + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Suppress PharData warnings in tests. + set_error_handler( + static function (): bool { + return true; + } + ); + + try { + $method->invoke( $subject, $archive_path, $target_path ); + } finally { + restore_error_handler(); + } + + self::assertFileDoesNotExist( $archive_path ); + self::assertFileDoesNotExist( $target_path ); + } + + /** + * Test extract_db_from_archive() returns when the target directory cannot be created. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_extract_db_from_archive_returns_when_target_directory_cannot_be_created(): void { + $subject = new MaxMindDb(); + $method = $this->set_method_accessibility( $subject, 'extract_db_from_archive' ); + $archive_path = $this->create_maxmind_archive(); + $target_path = $this->create_temp_dir( 'target' ) . '/missing/GeoLite2-Country.mmdb'; + + $mkdir_called = false; + + $this->mock_wp_filesystem_with_failed_mkdir( $mkdir_called ); + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Suppress PharData warnings in tests. + set_error_handler( + static function (): bool { + return true; + } + ); + + try { + $method->invoke( $subject, $archive_path, $target_path ); + } finally { + restore_error_handler(); + } + + self::assertTrue( $mkdir_called ); + + self::assertFileDoesNotExist( $archive_path ); + self::assertFileDoesNotExist( $target_path ); + } + + /** + * Test mkdir_p() variants. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_mkdir_p_variants(): void { + global $wp_filesystem; + + $subject = new MaxMindDb(); + $method = $this->set_method_accessibility( $subject, 'mkdir_p' ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_filesystem = Mockery::mock(); + $wp_filesystem->shouldReceive( 'is_dir' )->andReturn( true ); + + self::assertTrue( $method->invoke( $subject, '' ) ); + self::assertTrue( $method->invoke( $subject, 'C:/already-created' ) ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_filesystem = Mockery::mock(); + $wp_filesystem->shouldReceive( 'is_dir' )->andReturn( false ); + $wp_filesystem->shouldReceive( 'mkdir' )->andReturnUsing( + static function () use ( &$mkdir_called ): bool { + $mkdir_called = true; + + return false; + } + ); + + self::assertFalse( $method->invoke( $subject, '/cannot/create' ) ); + + $created = []; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_filesystem = Mockery::mock(); + $wp_filesystem->shouldReceive( 'is_dir' )->andReturnUsing( + static function ( string $path ) use ( &$created ): bool { + return isset( $created[ $path ] ); + } + ); + $wp_filesystem->shouldReceive( 'mkdir' )->andReturnUsing( + static function ( string $path ) use ( &$created ): bool { + $created[ $path ] = true; + + return true; + } + ); + + self::assertTrue( $method->invoke( $subject, '/root//child' ) ); + } + + /** + * Test mkdir_p() skips empty and existing segments. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_mkdir_p_skips_empty_and_existing_segments(): void { + global $wp_filesystem; + + $subject = new MaxMindDb(); + $method = $this->set_method_accessibility( $subject, 'mkdir_p' ); + + FunctionMocker::replace( + 'HCaptcha\Admin\wp_normalize_path', + static function ( string $path ): string { + return $path; + } + ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_filesystem = Mockery::mock(); + $wp_filesystem->shouldReceive( 'is_dir' )->andReturnUsing( + static function ( string $path ): bool { + return '/root' === $path; + } + ); + $wp_filesystem->shouldReceive( 'mkdir' )->andReturn( true ); + + self::assertTrue( $method->invoke( $subject, '/root//child' ) ); + } + + /** + * Create a temporary directory. + * + * @param string $suffix Directory suffix. + * + * @return string + */ + private function create_temp_dir( string $suffix ): string { + $dir = trailingslashit( wp_normalize_path( sys_get_temp_dir() ) ) . uniqid( 'hcaptcha-maxmind-' . $suffix . '-', true ); + + if ( ! is_dir( $dir ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $dir, 0777, true ); + } + + $this->temp_dirs[] = $dir; + + return $dir; + } + + /** + * Create a temporary file. + * + * @param string $filename File name. + * @param string $contents File contents. + * + * @return string + */ + private function create_temp_file( string $filename, string $contents ): string { + $dir = $this->create_temp_dir( 'file' ); + $file = $dir . '/' . $filename; + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $file, $contents ); + $this->temp_files[] = $file; + + return $file; + } + + /** + * Create a MaxMind-like tar archive. + * + * @param bool $include_db Whether the archive should include the database file. + * + * @return string + * @throws RuntimeException Cannot create archive. + * @noinspection PhpSameParameterValueInspection + */ + private function create_maxmind_archive( bool $include_db = true ): string { + $root = $this->create_temp_dir( 'archive-root' ); + $db_dir = $root . '/GeoLite2-Country_20260101'; + $archive_path = $this->create_temp_dir( 'archive' ) . '/country.tar'; + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $db_dir, 0777, true ); + + if ( $include_db ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $db_dir . '/GeoLite2-Country.mmdb', 'country-db' ); + } + + $tar = $this->get_tar_command(); + + if ( '' !== $tar ) { + $command = $tar . ' -cf ' . escapeshellarg( $archive_path ) . + ' -C ' . escapeshellarg( $root ) . ' GeoLite2-Country_20260101'; + $output = []; + $code = 0; + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( $command, $output, $code ); + + if ( 0 !== $code ) { + throw new RuntimeException( 'Cannot create MaxMind tar fixture.' ); + } + } else { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Suppress PharData warnings in tests. + set_error_handler( + static function (): bool { + return true; + } + ); + + try { + $archive = new \PharData( $archive_path ); + $archive->buildFromDirectory( $root ); + unset( $archive ); + } finally { + restore_error_handler(); + } + } + + $this->temp_files[] = $archive_path; + + return $archive_path; + } + + /** + * Get the tar command for creating MaxMind archive fixtures. + * + * @return string + */ + private function get_tar_command(): string { + if ( 'Windows' === PHP_OS_FAMILY ) { + return is_file( 'C:/Windows/System32/tar.exe' ) ? '"C:/Windows/System32/tar.exe"' : ''; + } + + $output = []; + $code = 0; + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( 'command -v tar', $output, $code ); + + return 0 === $code && ! empty( $output[0] ) ? escapeshellarg( $output[0] ) : ''; + } + + /** + * Move the default DB aside for tests that must exercise a missing DB. + * + * @return callable + */ + private function move_default_db_aside(): callable { + $default_path = WP_CONTENT_DIR . '/uploads/hcaptcha/GeoLite2-Country.mmdb'; + + if ( ! is_file( $default_path ) ) { + return static function (): void {}; + } + + $backup_path = $default_path . '.hcaptcha-test-' . uniqid( '', true ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + rename( $default_path, $backup_path ); + + return static function () use ( $default_path, $backup_path ): void { + if ( is_file( $backup_path ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + rename( $backup_path, $default_path ); + } + }; + } + + /** + * Mock successful WP_Filesystem initialization and operations. + * + * @return void + * @noinspection PhpVariableIsUsedOnlyInClosureInspection + */ + private function mock_successful_wp_filesystem(): void { + global $wp_filesystem; + + $test = $this; + + FunctionMocker::replace( 'HCaptcha\Admin\WP_Filesystem', true ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_filesystem = Mockery::mock(); + $wp_filesystem->shouldReceive( 'is_dir' )->andReturnUsing( + static function ( string $path ): bool { + return is_dir( $path ); + } + ); + $wp_filesystem->shouldReceive( 'mkdir' )->andReturnUsing( + static function ( string $path ): bool { + return is_dir( $path ) || mkdir( $path, 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + } + ); + $wp_filesystem->shouldReceive( 'move' )->andReturnUsing( + static function ( string $from, string $to ): bool { + if ( ! is_dir( dirname( $to ) ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( dirname( $to ), 0777, true ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local test fixture. + $contents = is_file( $from ) ? file_get_contents( $from ) : 'country-db'; + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $to, $contents ); + + if ( is_file( $from ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $from ); + } + + return true; + } + ); + $wp_filesystem->shouldReceive( 'rmdir' )->andReturnUsing( + static function ( string $dir ) use ( $test ): bool { + $test->remove_dir( $dir ); + + return true; + } + ); + } + + /** + * Mock WP_Filesystem initialization with mkdir() failure. + * + * @param bool $mkdir_called Whether mkdir() was called. + * + * @return void + */ + private function mock_wp_filesystem_with_failed_mkdir( bool &$mkdir_called ): void { + global $wp_filesystem; + + FunctionMocker::replace( 'HCaptcha\Admin\WP_Filesystem', true ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_filesystem = Mockery::mock(); + $wp_filesystem->shouldReceive( 'is_dir' )->andReturn( false ); + $wp_filesystem->shouldReceive( 'mkdir' )->andReturnUsing( + static function () use ( &$mkdir_called ): bool { + $mkdir_called = true; + + return false; + } + ); + $wp_filesystem->shouldReceive( 'move' )->never(); + $wp_filesystem->shouldReceive( 'rmdir' )->never(); + } + + /** + * Remove a directory recursively. + * + * @param string $dir Directory path. + * + * @return void + */ + private function remove_dir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $item ) { + if ( $item->isDir() ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + rmdir( $item->getPathname() ); + } else { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $item->getPathname() ); + } + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + rmdir( $dir ); + } +} diff --git a/tests/php/integration/Admin/NotificationsTest.php b/tests/php/integration/Admin/NotificationsTest.php index ba23dc350..89c64294e 100644 --- a/tests/php/integration/Admin/NotificationsTest.php +++ b/tests/php/integration/Admin/NotificationsTest.php @@ -20,6 +20,7 @@ /** * Test NotificationsTest class. * + * @group admin * @group notifications */ class NotificationsTest extends HCaptchaWPTestCase { @@ -81,6 +82,9 @@ public function test_get_notifications( string $setting ): void { $site_key = ''; $secret_key = ''; + delete_option( 'hcaptcha_settings' ); + hcaptcha()->init_hooks(); + $expected = [ 'register' => [ @@ -401,6 +405,9 @@ public function test_show(): void { $site_key = ''; $secret_key = ''; + delete_option( 'hcaptcha_settings' ); + hcaptcha()->init_hooks(); + add_filter( 'hcap_site_key', static function () use ( $site_key ) { @@ -661,6 +668,9 @@ public function test_show_without_shuffle(): void { $site_key = ''; $secret_key = ''; + delete_option( 'hcaptcha_settings' ); + hcaptcha()->init_hooks(); + add_filter( 'hcap_site_key', static function () use ( $site_key ) { @@ -705,6 +715,7 @@ static function () use ( $secret_key ) { * Test admin_enqueue_scripts(). * * @return void + * @noinspection JsonEncodingApiUsageInspection */ public function test_admin_enqueue_scripts(): void { $params = [ diff --git a/tests/php/integration/Admin/OnboardingWizardTest.php b/tests/php/integration/Admin/OnboardingWizardTest.php new file mode 100644 index 000000000..18a3a1c05 --- /dev/null +++ b/tests/php/integration/Admin/OnboardingWizardTest.php @@ -0,0 +1,504 @@ +make_wizard( $this->get_general_tab() ); + + self::assertSame( 10, has_action( 'plugins_loaded', [ $subject, 'init' ] ) ); + self::assertSame( 30, has_action( 'current_screen', [ $subject, 'maybe_handle_direct_step' ] ) ); + self::assertSame( 10, has_action( 'admin_enqueue_scripts', [ $subject, 'admin_enqueue_scripts' ] ) ); + self::assertFalse( has_action( 'wp_ajax_' . OnboardingWizard::UPDATE_ACTION, [ $subject, 'ajax_update' ] ) ); + } + + /** + * Test constructor and init_hooks() during ajax. + */ + public function test_init_hooks_when_doing_ajax(): void { + add_filter( 'wp_doing_ajax', '__return_true' ); + + $subject = $this->make_wizard( $this->get_general_tab() ); + + self::assertSame( 10, has_action( 'plugins_loaded', [ $subject, 'init' ] ) ); + self::assertSame( 10, has_action( 'wp_ajax_' . OnboardingWizard::UPDATE_ACTION, [ $subject, 'ajax_update' ] ) ); + self::assertFalse( has_action( 'current_screen', [ $subject, 'maybe_handle_direct_step' ] ) ); + self::assertFalse( has_action( 'admin_enqueue_scripts', [ $subject, 'admin_enqueue_scripts' ] ) ); + } + + /** + * Test init() stores settings tabs and completes the wizard when keys exist. + * + * @throws ReflectionException Reflection exception. + */ + public function test_init_completes_wizard_when_keys_exist(): void { + $this->refresh_settings( + [ + 'site_key' => 'site key', + 'secret_key' => 'secret key', + ] + ); + + $subject = $this->make_wizard( $this->get_general_tab() ); + + $subject->init(); + + $option = get_option( PluginSettingsBase::OPTION_NAME ); + + self::assertSame( 'completed', $option[ OnboardingWizard::OPTION_NAME ] ); + self::assertInstanceOf( General::class, $this->get_wizard_property( $subject, 'general_tab' ) ); + self::assertInstanceOf( Integrations::class, $this->get_wizard_property( $subject, 'integrations_tab' ) ); + } + + /** + * Test init() preserves an existing wizard state. + */ + public function test_init_preserves_existing_wizard_state(): void { + $this->refresh_settings( + [ + OnboardingWizard::OPTION_NAME => 'step 3', + 'site_key' => 'site key', + 'secret_key' => 'secret key', + ] + ); + + $subject = $this->make_wizard( $this->get_general_tab() ); + + $subject->init(); + + $option = get_option( PluginSettingsBase::OPTION_NAME ); + + self::assertSame( 'step 3', $option[ OnboardingWizard::OPTION_NAME ] ); + } + + /** + * Test maybe_handle_direct_step() exits when no step is provided. + * + * @noinspection PhpPossiblePolymorphicInvocationInspection + */ + public function test_maybe_handle_direct_step_without_step(): void { + $subject = $this->make_initialized_wizard( $this->get_general_tab() ); + + $subject->maybe_handle_direct_step(); + + self::assertSame( [], $subject->redirects ); + self::assertArrayNotHasKey( OnboardingWizard::OPTION_NAME, (array) get_option( PluginSettingsBase::OPTION_NAME ) ); + } + + /** + * Test maybe_handle_direct_step() with a bad nonce. + * + * @noinspection PhpPossiblePolymorphicInvocationInspection + */ + public function test_maybe_handle_direct_step_with_bad_nonce(): void { + $this->set_admin_user(); + + $_GET[ OnboardingWizard::STEP_PARAM ] = '3'; + $_GET[ OnboardingWizard::NONCE_PARAM ] = 'bad_nonce'; + + $subject = $this->make_initialized_wizard( $this->get_general_tab() ); + + $subject->maybe_handle_direct_step(); + + self::assertSame( [], $subject->redirects ); + self::assertArrayNotHasKey( OnboardingWizard::OPTION_NAME, (array) get_option( PluginSettingsBase::OPTION_NAME ) ); + } + + /** + * Test maybe_handle_direct_step(). + * + * @param string $step_param Step request parameter. + * @param string $expected_state Expected saved state. + * @param string $target_tab Target tab class name. + * + * @dataProvider dp_test_maybe_handle_direct_step + * @noinspection PhpPossiblePolymorphicInvocationInspection + */ + public function test_maybe_handle_direct_step( string $step_param, string $expected_state, string $target_tab ): void { + $this->set_admin_user(); + + $_GET[ OnboardingWizard::STEP_PARAM ] = $step_param; + $_GET[ OnboardingWizard::NONCE_PARAM ] = wp_create_nonce( OnboardingWizard::STEP_ACTION ); + + $general = $this->get_general_tab(); + $integrations = $this->get_integrations_tab(); + $subject = $this->make_initialized_wizard( $general ); + $target = General::class === $target_tab ? $general : $integrations; + $expected_url = $general->tab_url( $target ); + + $subject->maybe_handle_direct_step(); + + $option = get_option( PluginSettingsBase::OPTION_NAME ); + + self::assertSame( $expected_state, $option[ OnboardingWizard::OPTION_NAME ] ); + self::assertSame( [ $expected_url ], $subject->redirects ); + } + + /** + * Data provider for test_maybe_handle_direct_step(). + * + * @return array + */ + public function dp_test_maybe_handle_direct_step(): array { + return [ + 'general step' => [ '3', 'step 3', General::class ], + 'integrations step' => [ '7', 'step 7', Integrations::class ], + 'out of range step' => [ '99', 'step 1', General::class ], + ]; + } + + /** + * Test verify_request(). + */ + public function test_verify_request(): void { + $_GET[ OnboardingWizard::NONCE_PARAM ] = wp_create_nonce( OnboardingWizard::STEP_ACTION ); + + wp_set_current_user( 0 ); + + self::assertFalse( OnboardingWizard::verify_request( OnboardingWizard::STEP_ACTION ) ); + + $this->set_admin_user(); + + $_GET[ OnboardingWizard::NONCE_PARAM ] = [ 'bad' ]; + + self::assertFalse( OnboardingWizard::verify_request( OnboardingWizard::STEP_ACTION ) ); + + $_GET[ OnboardingWizard::NONCE_PARAM ] = 'bad_nonce'; + + self::assertFalse( OnboardingWizard::verify_request( OnboardingWizard::STEP_ACTION ) ); + + $_GET[ OnboardingWizard::NONCE_PARAM ] = wp_create_nonce( OnboardingWizard::STEP_ACTION ); + + self::assertTrue( OnboardingWizard::verify_request( OnboardingWizard::STEP_ACTION ) ); + } + + /** + * Test admin_enqueue_scripts() when not on an option screen. + */ + public function test_admin_enqueue_scripts_when_not_options_screen(): void { + $subject = $this->make_initialized_wizard( $this->get_general_tab() ); + + $subject->admin_enqueue_scripts(); + + self::assertFalse( wp_script_is( OnboardingWizard::HANDLE, 'registered' ) ); + } + + /** + * Test admin_enqueue_scripts() when the wizard is completed. + */ + public function test_admin_enqueue_scripts_when_completed(): void { + $this->refresh_settings( [ OnboardingWizard::OPTION_NAME => 'completed' ] ); + set_current_screen( 'settings_page_hcaptcha' ); + + $subject = $this->make_initialized_wizard( $this->get_general_tab() ); + + $subject->admin_enqueue_scripts(); + + self::assertFalse( wp_script_is( OnboardingWizard::HANDLE, 'registered' ) ); + } + + /** + * Test admin_enqueue_scripts(). + */ + public function test_admin_enqueue_scripts(): void { + $this->refresh_settings( [ OnboardingWizard::OPTION_NAME => 'step 5' ] ); + set_current_screen( 'settings_page_hcaptcha' ); + + $general = $this->get_general_tab(); + $subject = $this->make_initialized_wizard( $general ); + + $subject->admin_enqueue_scripts(); + + self::assertTrue( wp_script_is( OnboardingWizard::HANDLE, 'registered' ) ); + + $script = wp_scripts()->registered[ OnboardingWizard::HANDLE ]; + + self::assertSame( HCAPTCHA_URL . '/assets/js/onboarding-wizard.min.js', $script->src ); + self::assertSame( [ 'jquery', $general::HANDLE ], $script->deps ); + self::assertSame( HCAPTCHA_VERSION, $script->ver ); + self::assertStringContainsString( 'var HCaptchaOnboardingObject = ', $script->extra['data'] ); + self::assertStringContainsString( '"page":"general"', $script->extra['data'] ); + self::assertStringContainsString( '"currentStep":"step 5"', $script->extra['data'] ); + + self::assertTrue( wp_style_is( OnboardingWizard::HANDLE, 'registered' ) ); + + $style = wp_styles()->registered[ OnboardingWizard::HANDLE ]; + + self::assertSame( HCAPTCHA_URL . '/assets/css/onboarding-wizard.min.css', $style->src ); + self::assertSame( [], $style->deps ); + self::assertSame( HCAPTCHA_VERSION, $style->ver ); + } + + /** + * Test ajax_update() with a bad nonce. + */ + public function test_ajax_update_with_bad_nonce(): void { + $this->set_admin_user(); + + $_REQUEST['nonce'] = 'bad_nonce'; + $_POST['value'] = 'step 2'; + + $json = $this->run_ajax_update( $this->get_general_tab() ); + + self::assertSame( + '{"success":false,"data":"Your session has expired. Please reload the page."}', + $json + ); + } + + /** + * Test ajax_update() without permission. + */ + public function test_ajax_update_without_permission(): void { + wp_set_current_user( 0 ); + + $_REQUEST['nonce'] = wp_create_nonce( OnboardingWizard::UPDATE_ACTION ); + $_POST['value'] = 'step 2'; + + $json = $this->run_ajax_update( $this->get_general_tab() ); + + self::assertSame( + '{"success":false,"data":"You are not allowed to perform this action."}', + $json + ); + } + + /** + * Test ajax_update() with a bad value. + */ + public function test_ajax_update_with_bad_value(): void { + $this->set_admin_user(); + + $_REQUEST['nonce'] = wp_create_nonce( OnboardingWizard::UPDATE_ACTION ); + $_POST['value'] = 'bad'; + + $json = $this->run_ajax_update( $this->get_general_tab() ); + + self::assertSame( '{"success":false,"data":"Bad value"}', $json ); + } + + /** + * Test ajax_update(). + * + * @param string $value Expected saved value. + * + * @dataProvider dp_test_ajax_update + */ + public function test_ajax_update( string $value ): void { + $this->set_admin_user(); + + $_REQUEST['nonce'] = wp_create_nonce( OnboardingWizard::UPDATE_ACTION ); + $_POST['value'] = $value; + + $json = $this->run_ajax_update( $this->get_general_tab() ); + $option = get_option( PluginSettingsBase::OPTION_NAME ); + + self::assertSame( '{"success":true}', $json ); + self::assertSame( $value, $option[ OnboardingWizard::OPTION_NAME ] ); + } + + /** + * Data provider for test_ajax_update(). + * + * @return array + */ + public function dp_test_ajax_update(): array { + return [ + 'step' => [ 'step 8' ], + 'completed' => [ 'completed' ], + ]; + } + + /** + * Get an OnboardingWizard private property from a test subject subclass. + * + * @param OnboardingWizard $subject Subject. + * @param string $property_name Property name. + * + * @return mixed + * @throws ReflectionException Reflection exception. + */ + private function get_wizard_property( OnboardingWizard $subject, string $property_name ) { + $property = ( new ReflectionClass( OnboardingWizard::class ) )->getProperty( $property_name ); + $property->setAccessible( true ); + $value = $property->getValue( $subject ); + $property->setAccessible( false ); + + return $value; + } + /** + * Make a testable wizard. + * + * @param PluginSettingsBase $tab Tab. + * + * @return OnboardingWizard + */ + private function make_wizard( PluginSettingsBase $tab ): OnboardingWizard { + return new class( $tab ) extends OnboardingWizard { + /** + * Redirect URLs. + * + * @var string[] + */ + public array $redirects = []; + + /** + * Redirect after direct step changes are applied. + * + * @param string $url Redirect URL. + * + * @return void + */ + protected function redirect_after_direct_step( string $url ): void { + $this->redirects[] = $url; + } + }; + } + + /** + * Make an initialized testable wizard. + * + * @param PluginSettingsBase $tab Tab. + * + * @return OnboardingWizard + */ + private function make_initialized_wizard( PluginSettingsBase $tab ): OnboardingWizard { + $subject = $this->make_wizard( $tab ); + + $subject->init(); + + return $subject; + } + + /** + * Run ajax_update() and return JSON. + * + * @param PluginSettingsBase $tab Tab. + * + * @return string + * @noinspection ThrowRawExceptionInspection + */ + private function run_ajax_update( PluginSettingsBase $tab ): string { + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () { + return static function () { + throw new Exception( 'wp_die' ); + }; + } + ); + + $subject = $this->make_wizard( $tab ); + + ob_start(); + + try { + $subject->ajax_update(); + } catch ( Exception $exception ) { + self::assertSame( 'wp_die', $exception->getMessage() ); + } + + return ob_get_clean(); + } + + /** + * Set an administrator as the current user. + * + * @return void + */ + private function set_admin_user(): void { + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + + wp_set_current_user( $user_id ); + } + + /** + * Refresh plugin settings from raw option values. + * + * @param array $settings Settings. + * + * @return void + */ + private function refresh_settings( array $settings = [] ): void { + update_option( PluginSettingsBase::OPTION_NAME, $settings ); + hcaptcha()->init_hooks(); + } + + /** + * Get General tab. + * + * @return General + */ + private function get_general_tab(): General { + $tab = hcaptcha()->settings()->get_tab( General::class ); + + self::assertInstanceOf( General::class, $tab ); + + return $tab; + } + + /** + * Get Integrations tab. + * + * @return Integrations + */ + private function get_integrations_tab(): Integrations { + $tab = hcaptcha()->settings()->get_tab( Integrations::class ); + + self::assertInstanceOf( Integrations::class, $tab ); + + return $tab; + } +} diff --git a/tests/php/integration/Admin/PluginStatsTest.php b/tests/php/integration/Admin/PluginStatsTest.php new file mode 100644 index 000000000..09efffb7f --- /dev/null +++ b/tests/php/integration/Admin/PluginStatsTest.php @@ -0,0 +1,220 @@ + [] ] ); + hcaptcha()->init_hooks(); + + add_filter( + 'pre_http_request', + static function () use ( &$called ) { + $called = true; + + return null; + } + ); + + ( new PluginStats() )->send_plugin_stats(); + + self::assertFalse( $called ); + } + + /** + * Test send_plugin_stats() sends the expected request payload. + * + * @return void + * @noinspection JsonEncodingApiUsageInspection + */ + public function test_send_plugin_stats_sends_successful_request(): void { + $request = []; + + add_filter( + 'pre_http_request', + static function ( $preempt, array $parsed_args, string $url ) use ( &$request ) { + $request = [ $url, $parsed_args ]; + + return [ + 'response' => [ 'code' => 202 ], + 'body' => '', + ]; + }, + 10, + 3 + ); + + ( new PluginStats() )->send_plugin_stats( true ); + + self::assertSame( 'https://a.hcaptcha.com/api/event', $request[0] ); + self::assertSame( 'application/json', $request[1]['headers']['Content-Type'] ); + self::assertSame( '127.0.0.1', $request[1]['headers']['X-Forwarded-For'] ); + + $body = json_decode( $request[1]['body'], true ); + + self::assertSame( 'wp-plugin.hcaptcha.com', $body['d'] ); + self::assertSame( 'plugin-stats', $body['n'] ); + self::assertIsArray( $body['props'] ); + self::assertArrayHasKey( 'hCaptcha', $body['props'] ); + } + + /** + * Test send_plugin_stats() handles a WP_Error response in debug mode. + * + * @return void + */ + public function test_send_plugin_stats_handles_wp_error_response(): void { + add_filter( + 'pre_http_request', + static function () { + return new WP_Error( 'hcaptcha_error', 'Stats endpoint unavailable.' ); + } + ); + + ( new PluginStats() )->send_plugin_stats( true ); + + self::assertTrue( true ); + } + + /** + * Test send_plugin_stats() handles an unexpected HTTP response code. + * + * @return void + */ + public function test_send_plugin_stats_handles_unexpected_response_code(): void { + add_filter( + 'pre_http_request', + static function () { + return [ + 'response' => [ 'code' => 500 ], + 'body' => '', + ]; + } + ); + + ( new PluginStats() )->send_plugin_stats( true ); + + self::assertTrue( true ); + } + + /** + * Test get_plugin_stats(). + * + * @return void + */ + public function test_get_plugin_stats(): void { + update_option( + 'hcaptcha_settings', + [ + 'mode' => General::MODE_LIVE, + 'license' => 'pro', + 'site_key' => 'site-key', + 'secret_key' => 'secret-key', + 'api_host' => 'https://enterprise-api.example.test', + ] + ); + hcaptcha()->init_hooks(); + + $stats = ( new PluginStats() )->get_plugin_stats(); + + self::assertSame( HCAPTCHA_VERSION, $stats['hCaptcha'] ); + self::assertSame( 'Enterprise', $stats['License'] ); + self::assertSame( 1, $stats['Site key'] ); + self::assertSame( 1, $stats['Secret key'] ); + self::assertSame( (int) is_multisite(), $stats['Multisite'] ); + self::assertArrayHasKey( 'Active', $stats ); + self::assertLessThanOrEqual( 30, count( $stats ) ); + } + + /** + * Test get_plugin_stats() when the SystemInfo tab is unavailable. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_get_plugin_stats_without_system_info_tab(): void { + $settings = hcaptcha()->settings(); + $tabs = $this->get_protected_property( $settings, 'tabs' ); + + $this->set_protected_property( $settings, 'tabs', [] ); + + try { + self::assertSame( [], ( new PluginStats() )->get_plugin_stats() ); + } finally { + $this->set_protected_property( $settings, 'tabs', $tabs ); + } + } + + /** + * Test get_active() removes WP Core and limits the returned string length. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_active_limits_value_length(): void { + $subject = new PluginStats(); + $method = $this->set_method_accessibility( $subject, 'get_active' ); + $fields = [ + 'wp-core' => [ 'label' => 'WP Core' ], + ]; + + for ( $i = 0; $i < 80; $i++ ) { + $fields[ 'field-' . $i ] = [ + 'label' => str_repeat( 'A', 40 ) . $i, + ]; + } + + $active = $method->invoke( $subject, $fields ); + + self::assertStringNotContainsString( 'WP Core', $active ); + self::assertLessThanOrEqual( 2000, strlen( $active ) ); + } +} diff --git a/tests/php/integration/Admin/PrivacyTest.php b/tests/php/integration/Admin/PrivacyTest.php new file mode 100644 index 000000000..12c24820d --- /dev/null +++ b/tests/php/integration/Admin/PrivacyTest.php @@ -0,0 +1,52 @@ +add_privacy_message(); + + self::assertTrue( true ); + } + + /** + * Test get_privacy_message() and add_privacy_message(). + * + * @return void + */ + public function test_get_privacy_message_and_add_privacy_message(): void { + $subject = new Privacy(); + $message = $subject->get_privacy_message(); + + self::assertStringContainsString( 'wp-suggested-text', $message ); + self::assertStringContainsString( 'https://www.hcaptcha.com/privacy', $message ); + self::assertStringContainsString( 'https://www.hcaptcha.com/terms', $message ); + + set_current_screen( 'dashboard' ); + do_action( 'admin_init' ); + + self::assertTrue( true ); + } +} diff --git a/tests/php/integration/Admin/SupportModalTest.php b/tests/php/integration/Admin/SupportModalTest.php new file mode 100644 index 000000000..a2c33003b --- /dev/null +++ b/tests/php/integration/Admin/SupportModalTest.php @@ -0,0 +1,352 @@ +enqueue_assets(); + + self::assertFalse( wp_style_is( $handle ) ); + + $this->set_hcaptcha_admin_page(); + $subject->enqueue_assets(); + + self::assertTrue( wp_style_is( $handle ) ); + + $style = wp_styles()->registered[ $handle ]; + self::assertSame( HCAPTCHA_URL . '/assets/css/support-modal.min.css', $style->src ); + self::assertSame( [], $style->deps ); + self::assertSame( HCAPTCHA_VERSION, $style->ver ); + + $script = wp_scripts()->registered[ $handle ]; + self::assertSame( HCAPTCHA_URL . '/assets/js/support-modal.min.js', $script->src ); + self::assertSame( [], $script->deps ); + self::assertSame( HCAPTCHA_VERSION, $script->ver ); + self::assertSame( 1, $script->extra['group'] ); + self::assertStringContainsString( 'var HCaptchaSupportModalObject = ', $script->extra['data'] ); + self::assertStringContainsString( '"systemInfo"', $script->extra['data'] ); + self::assertStringContainsString( '### Begin System Info ###', $script->extra['data'] ); + } + + /** + * Test render_actions_start() and render_actions_end(). + * + * @return void + */ + public function test_render_actions(): void { + $subject = new SupportModal(); + + ob_start(); + $subject->render_actions_start(); + $subject->render_actions_end(); + $output = ob_get_clean(); + + self::assertSame( '', $output ); + + $this->set_hcaptcha_admin_page(); + + ob_start(); + $subject->render_actions_start(); + $subject->render_actions_end(); + $output = ob_get_clean(); + + self::assertStringContainsString( '
    ', $output ); + self::assertStringContainsString( '
    ', $output ); + } + + /** + * Test render_button(). + * + * @return void + */ + public function test_render_button(): void { + $subject = new SupportModal(); + + ob_start(); + $subject->render_button(); + $output = ob_get_clean(); + + self::assertSame( '', $output ); + + $this->set_hcaptcha_admin_page(); + + ob_start(); + $subject->render_button(); + $output = ob_get_clean(); + + self::assertStringContainsString( 'class="button button-secondary hcaptcha-help-button"', $output ); + self::assertStringContainsString( 'aria-controls="hcaptcha-support-modal"', $output ); + self::assertStringContainsString( 'Help', $output ); + } + + /** + * Test render_modal(). + * + * @return void + */ + public function test_render_modal(): void { + $this->set_hcaptcha_admin_page(); + + $subject = new SupportModal(); + + ob_start(); + $subject->render_modal(); + $output = ob_get_clean(); + + self::assertStringContainsString( 'id="hcaptcha-support-modal"', $output ); + self::assertStringContainsString( 'role="dialog"', $output ); + self::assertStringContainsString( 'aria-modal="true"', $output ); + self::assertStringContainsString( 'Need help with hCaptcha?', $output ); + self::assertStringContainsString( 'Report a bug', $output ); + self::assertStringContainsString( 'Request a feature', $output ); + self::assertStringContainsString( 'Ask a setup question', $output ); + self::assertStringContainsString( 'No information is sent automatically.', $output ); + self::assertStringContainsString( 'id="hcaptcha-support-include-system-info" checked', $output ); + self::assertStringContainsString( 'Add system information to the report', $output ); + self::assertStringContainsString( 'Continue on GitHub', $output ); + self::assertStringContainsString( 'Continue on WordPress.org', $output ); + self::assertStringContainsString( 'class="hcaptcha-support-action-help"', $output ); + self::assertStringContainsString( 'aria-expanded="false"', $output ); + self::assertStringContainsString( 'data-hcaptcha-support-continue="wordpress" aria-describedby="hcaptcha-support-wordpress-description hcaptcha-support-wordpress-copy-description" disabled', $output ); + self::assertStringContainsString( 'Best for general setup questions and public community support.', $output ); + self::assertStringContainsString( 'First copy the report', $output ); + self::assertStringContainsString( 'Copy report', $output ); + self::assertStringContainsString( 'data-hcaptcha-support-action="copy"', $output ); + self::assertStringNotContainsString( 'secret_key', $output ); + + $copy_pos = strpos( $output, 'data-hcaptcha-support-copy' ); + $status_pos = strpos( $output, 'id="hcaptcha-support-status"' ); + $area_pos = strpos( $output, 'id="hcaptcha-support-area"' ); + $actual_pos = strpos( $output, 'id="hcaptcha-support-actual"' ); + $alternatives_pos = strpos( $output, 'id="hcaptcha-support-alternatives"' ); + $tried_pos = strpos( $output, 'id="hcaptcha-support-tried"' ); + $details_pos = strpos( $output, 'id="hcaptcha-support-details"' ); + + self::assertNotFalse( $copy_pos ); + self::assertNotFalse( $status_pos ); + self::assertNotFalse( $area_pos ); + self::assertNotFalse( $actual_pos ); + self::assertNotFalse( $alternatives_pos ); + self::assertNotFalse( $tried_pos ); + self::assertNotFalse( $details_pos ); + self::assertLessThan( $status_pos, $copy_pos ); + self::assertLessThan( $actual_pos, $area_pos ); + self::assertLessThan( $details_pos, $actual_pos ); + self::assertLessThan( $details_pos, $alternatives_pos ); + self::assertLessThan( $details_pos, $tried_pos ); + } + + /** + * Test get_system_info(). + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_system_info(): void { + $this->set_hcaptcha_admin_page(); + + $subject = new SupportModal(); + $method = $this->set_method_accessibility( $subject, 'get_system_info' ); + $data = $method->invoke( $subject ); + + self::assertStringContainsString( '### Begin System Info ###', $data ); + self::assertStringContainsString( '-- hCaptcha Info --', $data ); + self::assertStringContainsString( '### End System Info ###', $data ); + } + + /** + * Test render_modal() outside an hCaptcha admin page. + * + * @return void + */ + public function test_render_modal_returns_outside_hcaptcha_admin_page(): void { + $subject = new SupportModal(); + + ob_start(); + $subject->render_modal(); + $output = ob_get_clean(); + + self::assertSame( '', $output ); + } + + /** + * Test is_hcaptcha_admin_page() resolves hCaptcha from the current screen. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_is_hcaptcha_admin_page_from_current_screen(): void { + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + $subject = new SupportModal(); + $method = $this->set_method_accessibility( $subject, 'is_hcaptcha_admin_page' ); + + wp_set_current_user( $user_id ); + set_current_screen( 'settings_page_hcaptcha-network' ); + $_GET['page'] = 'settings'; + + self::assertTrue( $method->invoke( $subject ) ); + } + + /** + * Test is_hcaptcha_admin_page() when get_current_screen() is unavailable. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_is_hcaptcha_admin_page_without_current_screen_function(): void { + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + $subject = new SupportModal(); + $method = $this->set_method_accessibility( $subject, 'is_hcaptcha_admin_page' ); + + set_current_screen( 'some' ); + wp_set_current_user( $user_id ); + $_GET['page'] = 'settings'; + + FunctionMocker::replace( + 'function_exists', + static function ( string $function_name ): bool { + return 'get_current_screen' !== $function_name; + } + ); + + self::assertFalse( $method->invoke( $subject ) ); + } + + /** + * Test is_hcaptcha_admin_page() when the current screen is unavailable. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_is_hcaptcha_admin_page_without_current_screen(): void { + global $current_screen; + + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + $subject = new SupportModal(); + $method = $this->set_method_accessibility( $subject, 'is_hcaptcha_admin_page' ); + + set_current_screen( 'some' ); + wp_set_current_user( $user_id ); + $_GET['page'] = 'settings'; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $current_screen = null; + + self::assertNull( get_current_screen() ); + self::assertFalse( $method->invoke( $subject ) ); + } + + /** + * Test get_system_info() without settings. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_system_info_without_settings(): void { + $main = hcaptcha(); + $settings = $this->get_protected_property( $main, 'settings' ); + + $this->set_protected_property( $main, 'settings', null ); + + $subject = new SupportModal(); + $method = $this->set_method_accessibility( $subject, 'get_system_info' ); + + try { + self::assertSame( '', $method->invoke( $subject ) ); + } finally { + $this->set_protected_property( $main, 'settings', $settings ); + } + } + + /** + * Test get_system_info() without the SystemInfo tab. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_system_info_without_system_info_tab(): void { + $settings = hcaptcha()->settings(); + $tabs = $this->get_protected_property( $settings, 'tabs' ); + + $this->set_protected_property( $settings, 'tabs', [] ); + + $subject = new SupportModal(); + $method = $this->set_method_accessibility( $subject, 'get_system_info' ); + + try { + self::assertSame( '', $method->invoke( $subject ) ); + } finally { + $this->set_protected_property( $settings, 'tabs', $tabs ); + } + } + + /** + * Set the current request to an hCaptcha admin page. + * + * @return void + */ + private function set_hcaptcha_admin_page(): void { + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + + wp_set_current_user( $user_id ); + set_current_screen( 'settings_page_hcaptcha' ); + + $_GET['page'] = 'hcaptcha'; + } +} diff --git a/tests/php/integration/Admin/WhatsNewTest.php b/tests/php/integration/Admin/WhatsNewTest.php index 932f6ab83..ffc17df6e 100644 --- a/tests/php/integration/Admin/WhatsNewTest.php +++ b/tests/php/integration/Admin/WhatsNewTest.php @@ -17,6 +17,7 @@ /** * Test WhatsNew class. * + * @group admin * @group whats-new */ class WhatsNewTest extends HCaptchaWPTestCase { @@ -43,12 +44,14 @@ public function test_init_and_init_hooks( bool $wizard_completed ): void { self::assertSame( 9, has_action( 'admin_print_footer_scripts', [ $subject, 'enqueue_assets' ] ) ); self::assertSame( 10, has_action( 'admin_footer', [ $subject, 'maybe_show_popup' ] ) ); self::assertSame( 10, has_action( 'wp_ajax_hcaptcha-mark-shown', [ $subject, 'mark_shown' ] ) ); + self::assertSame( 10, has_action( 'wp_ajax_hcaptcha-render-whats-new-popup', [ $subject, 'render_popup_ajax' ] ) ); self::assertSame( 1010, has_filter( 'update_footer', [ $subject, 'update_footer' ] ) ); } else { self::assertFalse( has_action( 'kagg_settings_tab', [ $subject, 'action_settings_tab' ] ) ); self::assertFalse( has_action( 'admin_print_footer_scripts', [ $subject, 'enqueue_assets' ] ) ); self::assertFalse( has_action( 'admin_footer', [ $subject, 'maybe_show_popup' ] ) ); self::assertFalse( has_action( 'wp_ajax_hcaptcha-mark-shown', [ $subject, 'mark_shown' ] ) ); + self::assertFalse( has_action( 'wp_ajax_hcaptcha-render-whats-new-popup', [ $subject, 'render_popup_ajax' ] ) ); self::assertFalse( has_filter( 'update_footer', [ $subject, 'update_footer' ] ) ); } } @@ -84,15 +87,18 @@ public function test_action_settings_tab(): void { * * @return void * @throws ReflectionException ReflectionException. + * @noinspection JsonEncodingApiUsageInspection */ public function test_enqueue_assets(): void { $handle = 'hcaptcha-whats-new'; $action = 'hcaptcha-mark-shown'; + $render_action = 'hcaptcha-render-whats-new-popup'; $params = [ - 'ajaxUrl' => 'http://test.test/wp-admin/admin-ajax.php', - 'markShownAction' => $action, - 'markShownNonce' => wp_create_nonce( $action ), - 'whatsNewParam' => 'whats_new', + 'ajaxUrl' => 'http://test.test/wp-admin/admin-ajax.php', + 'markShownAction' => $action, + 'markShownNonce' => wp_create_nonce( $action ), + 'renderPopupAction' => $render_action, + 'renderPopupNonce' => wp_create_nonce( $render_action ), ]; $expected_extra = [ 'group' => 1, @@ -223,6 +229,51 @@ static function () use ( &$die_arr ) { self::assertSame( '{"success":true}', $json ); } + /** + * Test render_popup_ajax(). + * + * @return void + */ + public function test_render_popup_ajax(): void { + wp_set_current_user( 1 ); + + $action = 'hcaptcha-render-whats-new-popup'; + $nonce = wp_create_nonce( $action ); + $version = '4.13.0'; + $die_arr = []; + $expected = [ + '', + '', + [ 'response' => null ], + ]; + + $_REQUEST['action'] = $action; + $_REQUEST['nonce'] = $nonce; + $_POST['version'] = $version; + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + $subject = Mockery::mock( WhatsNew::class )->makePartial(); + + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'render_popup' )->once()->with( 'whats_new_4_13_0', true ); + + ob_start(); + $subject->render_popup_ajax(); + $json = ob_get_clean(); + + self::assertSame( $expected, $die_arr ); + self::assertSame( '{"success":true,"data":{"html":""}}', $json ); + } + /** * Test mark_shown() with bad ajax referer. * @@ -354,41 +405,27 @@ public function test_render_popup(): void { self::assertSame( '', ob_get_clean() ); // Mocked method. - $version = '4.13.0'; - $expected = << -
    -
    - -
    -
    - Icon -
    -
    -

    - What's New in hCaptcha $version -

    -
    -
    -
    -
    -
    -
    -
    - lightbox-image -
    - -HTML; + $version = '4.13.0'; ob_start(); $subject->render_popup( $method, true ); - self::assertSame( $expected, ob_get_clean() ); + $html = ob_get_clean(); + + self::assertStringContainsString( + 'id="hcaptcha-whats-new-modal" class="hcaptcha-whats-new-modal"', + $html + ); + self::assertStringContainsString( 'What's New in hCaptcha', $html ); + self::assertStringContainsString( '', $html ); + 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.0.0"', $html ); + self::assertStringContainsString( 'data-version="' . $version . '"', $html ); + self::assertStringNotContainsString( 'whats_new=', $html ); + self::assertStringContainsString( '
    ', $html ); } /** @@ -567,7 +604,7 @@ static function ( $args ) {
    Restart wizard @@ -863,6 +900,166 @@ public function test_update_whats_new(): void { self::assertSame( $version, $settings->get( $key ) ); } + /** + * Test render_popup_ajax() with a bad ajax referer. + * + * @return void + */ + public function test_render_popup_ajax_with_bad_ajax_referer(): void { + $die_arr = []; + $expected = [ + '', + '', + [ 'response' => null ], + ]; + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + ob_start(); + ( new WhatsNew() )->render_popup_ajax(); + $json = ob_get_clean(); + + self::assertSame( $expected, $die_arr ); + self::assertSame( + 0, + strpos( + $json, + '{"success":false,"data":"Your session has expired. Please reload the page."}' + ) + ); + } + + /** + * Test render_popup_ajax() when a user has no caps. + * + * @return void + */ + public function test_render_popup_ajax_when_user_has_no_caps(): void { + $action = 'hcaptcha-render-whats-new-popup'; + $nonce = wp_create_nonce( $action ); + + $_REQUEST['action'] = $action; + $_REQUEST['nonce'] = $nonce; + + $die_arr = []; + $expected = [ + '', + '', + [ 'response' => null ], + ]; + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + ob_start(); + ( new WhatsNew() )->render_popup_ajax(); + $json = ob_get_clean(); + + self::assertSame( $expected, $die_arr ); + self::assertSame( + 0, + strpos( + $json, + '{"success":false,"data":"You are not allowed to perform this action."}' + ) + ); + } + + /** + * Test render_popup_ajax() with an unavailable version. + * + * @return void + */ + public function test_render_popup_ajax_with_unavailable_version(): void { + $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + $action = 'hcaptcha-render-whats-new-popup'; + $nonce = wp_create_nonce( $action ); + + wp_set_current_user( $user_id ); + + $_REQUEST['action'] = $action; + $_REQUEST['nonce'] = $nonce; + $_POST['version'] = '1.0.0'; + + $die_arr = []; + $expected = [ + '', + '', + [ 'response' => null ], + ]; + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + ob_start(); + ( new WhatsNew() )->render_popup_ajax(); + $json = ob_get_clean(); + + self::assertSame( $expected, $die_arr ); + self::assertStringContainsString( + '{"success":false,"data":"The requested version is not available."}', + $json + ); + } + + /** + * Test whats_new_4_25_0(). + * + * @return void + */ + public function test_whats_new_4_25_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_4_25_0(); + + $html = ob_get_clean(); + + self::assertStringContainsString( 'Migration Wizard', $html ); + self::assertStringContainsString( 'Open Migration Wizard', $html ); + self::assertStringContainsString( 'assets/images/migration-wizard.png', $html ); + } + /** * Normalize HTML output for stable comparisons. * diff --git a/tests/php/integration/BeaverBuilder/ContactTest.php b/tests/php/integration/BeaverBuilder/ContactTest.php index 585e80485..7eb5f501a 100644 --- a/tests/php/integration/BeaverBuilder/ContactTest.php +++ b/tests/php/integration/BeaverBuilder/ContactTest.php @@ -12,6 +12,7 @@ use FLBuilderModule; use HCaptcha\BeaverBuilder\Contact; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use Mockery; @@ -103,6 +104,7 @@ public function test_verify(): void { $subject = new Contact(); $this->prepare_verify_post( 'hcaptcha_beaver_builder_nonce', 'hcaptcha_beaver_builder' ); + $this->prepare_widget_id(); $_POST['name'] = 'John Doe'; $_POST['subject'] = 'Test subject'; @@ -138,6 +140,7 @@ static function () use ( &$die_arr ) { ); $this->prepare_verify_post( 'hcaptcha_beaver_builder_nonce', 'hcaptcha_beaver_builder', false ); + $this->prepare_widget_id(); $subject = new Contact(); @@ -148,4 +151,18 @@ static function () use ( &$die_arr ) { self::assertSame( '{"error":true,"message":"The hCaptcha is invalid."}', $json ); self::assertSame( $expected, $die_arr ); } + + /** + * Prepare hCaptcha widget id. + * + * @return void + */ + private function prepare_widget_id(): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'bb-plugin/fl-builder.php' ], + 'form_id' => 'contact', + ] + ); + } } diff --git a/tests/php/integration/BeaverBuilder/LoginTest.php b/tests/php/integration/BeaverBuilder/LoginTest.php index 8c7ffe537..092f51054 100644 --- a/tests/php/integration/BeaverBuilder/LoginTest.php +++ b/tests/php/integration/BeaverBuilder/LoginTest.php @@ -12,6 +12,7 @@ use FLBuilderModule; use HCaptcha\BeaverBuilder\Login; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use Mockery; use WP_Error; @@ -75,7 +76,7 @@ public function test_add_beaver_builder_captcha(): void { 'action' => 'hcaptcha_login', 'name' => 'hcaptcha_login_nonce', 'id' => [ - 'source' => [], + 'source' => [ 'bb-plugin/fl-builder.php' ], 'form_id' => 'login', ], ]; @@ -84,10 +85,9 @@ public function test_add_beaver_builder_captcha(): void { $expected = 'some output more'; $module = Mockery::mock( 'alias:' . FLBuilderModule::class ); - $subject = Mockery::mock( Login::class )->makePartial(); + add_filter( 'hcap_login_limit_exceeded', '__return_true' ); - $subject->shouldAllowMockingProtectedMethods(); - $subject->shouldReceive( 'is_login_limit_exceeded' )->andReturn( true ); + $subject = new Login(); // Some output. self::assertSame( $some_out, $subject->add_beaver_builder_captcha( $some_out, $module ) ); @@ -111,10 +111,9 @@ public function test_add_beaver_builder_captcha_when_login_limit_not_exceeded(): $some_out = 'some output'; $module = Mockery::mock( 'alias:' . FLBuilderModule::class ); - $subject = Mockery::mock( Login::class )->makePartial(); + add_filter( 'hcap_login_limit_exceeded', '__return_false' ); - $subject->shouldAllowMockingProtectedMethods(); - $subject->shouldReceive( 'is_login_limit_exceeded' )->andReturn( false ); + $subject = new Login(); self::assertSame( $some_out, $subject->add_beaver_builder_captcha( $some_out, $module ) ); } @@ -132,11 +131,11 @@ public function test_verify(): void { $user = new WP_User( 1 ); $this->prepare_verify_post_html( 'hcaptcha_login_nonce', 'hcaptcha_login' ); + $this->prepare_widget_id(); - $subject = Mockery::mock( Login::class )->makePartial(); + add_filter( 'hcap_login_limit_exceeded', '__return_true' ); - $subject->shouldAllowMockingProtectedMethods(); - $subject->shouldReceive( 'is_login_limit_exceeded' )->andReturn( true ); + $subject = new Login(); self::assertSame( $user, $subject->verify( $user, 'some password' ) ); } @@ -151,15 +150,15 @@ public function test_verify_not_verified(): void { $GLOBALS['wp_current_filter'] = [ 'wp_ajax_nopriv_fl_builder_login_form_submit' ]; $user = new WP_User( 1 ); - $error_message = 'hCaptcha error: The hCaptcha is invalid.'; - $expected = new WP_Error( 'invalid_hcaptcha', $error_message, 400 ); + $error_message = 'The hCaptcha is invalid.'; + $expected = new WP_Error( 'fail', $error_message, 400 ); $this->prepare_verify_post_html( 'hcaptcha_login_nonce', 'hcaptcha_login', false ); + $this->prepare_widget_id(); - $subject = Mockery::mock( Login::class )->makePartial(); + add_filter( 'hcap_login_limit_exceeded', '__return_true' ); - $subject->shouldAllowMockingProtectedMethods(); - $subject->shouldReceive( 'is_login_limit_exceeded' )->andReturn( true ); + $subject = new Login(); self::assertEquals( $expected, $subject->verify( $user, 'some password' ) ); } @@ -188,11 +187,24 @@ public function test_verify_not_verified_when_login_limit_not_exceeded(): void { $user = new WP_User( 1 ); - $subject = Mockery::mock( Login::class )->makePartial(); + add_filter( 'hcap_login_limit_exceeded', '__return_false' ); - $subject->shouldAllowMockingProtectedMethods(); - $subject->shouldReceive( 'is_login_limit_exceeded' )->andReturn( false ); + $subject = new Login(); self::assertEquals( $user, $subject->verify( $user, 'some password' ) ); } + + /** + * Prepare hCaptcha widget id. + * + * @return void + */ + private function prepare_widget_id(): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'bb-plugin/fl-builder.php' ], + 'form_id' => 'login', + ] + ); + } } diff --git a/tests/php/integration/Blocksy/NewsletterSubscribeTest.php b/tests/php/integration/Blocksy/NewsletterSubscribeTest.php index 0abf2407f..3c294e3eb 100644 --- a/tests/php/integration/Blocksy/NewsletterSubscribeTest.php +++ b/tests/php/integration/Blocksy/NewsletterSubscribeTest.php @@ -8,6 +8,7 @@ namespace HCaptcha\Tests\Integration\Blocksy; use HCaptcha\Blocksy\NewsletterSubscribe; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use Mockery; use ReflectionException; @@ -50,6 +51,8 @@ public function test_constructor_and_init_hooks(): void { has_action( 'wp_ajax_nopriv_blc_newsletter_subscribe_process_ajax_subscribe', [ $subject, 'verify' ] ) ); self::assertSame( 10, has_action( 'wp_head', [ $subject, 'print_inline_styles' ] ) ); + self::assertSame( 9, has_action( 'wp_print_footer_scripts', [ $subject, 'enqueue_scripts' ] ) ); + self::assertSame( 10, has_filter( 'script_loader_tag', [ $subject, 'add_type_module' ] ) ); } /** @@ -88,6 +91,7 @@ public function test_verify(): void { 'hcaptcha_blocksy_newsletter_subscribe_nonce', 'hcaptcha_blocksy_newsletter_subscribe' ); + $this->prepare_widget_id(); $_POST['email'] = 'test@example.com'; $_POST['group'] = 'newsletter'; @@ -98,6 +102,41 @@ public function test_verify(): void { $subject->verify(); } + /** + * Test add_type_module(). + * + * @return void + */ + public function test_add_type_module(): void { + $subject = new NewsletterSubscribe(); + + // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript + $tag = ''; + + self::assertSame( $tag, $subject->add_type_module( $tag, 'other-handle', '' ) ); + self::assertStringContainsString( + 'type="module"', + $subject->add_type_module( $tag, 'hcaptcha-blocksy', '' ) + ); + } + + /** + * Test enqueue_scripts(). + * + * @return void + */ + public function test_enqueue_scripts(): void { + $subject = new NewsletterSubscribe(); + + $subject->enqueue_scripts(); + self::assertFalse( wp_script_is( 'hcaptcha-blocksy' ) ); + + hcaptcha()->form_shown = true; + + $subject->enqueue_scripts(); + self::assertTrue( wp_script_is( 'hcaptcha-blocksy' ) ); + } + /** * Test verify() when not verified. * @@ -117,6 +156,7 @@ public function test_verify_not_verified(): void { 'hcaptcha_blocksy_newsletter_subscribe', false ); + $this->prepare_widget_id(); $_POST['email'] = 'test@example.com'; $_POST['group'] = 'newsletter'; @@ -146,6 +186,52 @@ static function () use ( &$die_arr ) { self::assertSame( $expected, $die_arr ); } + /** + * Test verify() when widget id is missing. + * + * @return void + * @noinspection JsonEncodingApiUsageInspection + */ + public function test_verify_missing_widget_id(): void { + $die_arr = []; + $expected = [ + '', + '', + [ 'response' => null ], + ]; + + $this->prepare_verify_post( + 'hcaptcha_blocksy_newsletter_subscribe_nonce', + 'hcaptcha_blocksy_newsletter_subscribe' + ); + + $_POST['email'] = 'test@example.com'; + $_POST['group'] = 'newsletter'; + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + $subject = new NewsletterSubscribe(); + + ob_start(); + $subject->verify(); + $json = ob_get_clean(); + + $data = json_decode( $json, true ); + + self::assertFalse( $data['success'] ); + self::assertSame( 'no', $data['data']['result'] ); + self::assertSame( 'Bad hCaptcha signature!', $data['data']['message'] ); + self::assertSame( $expected, $die_arr ); + } + /** * Test get_entry(). * @@ -188,9 +274,36 @@ public function test_get_entry(): void { // 'Email' key overwrites 'email' after strtolower. self::assertSame( 'upper@example.com', $actual['data']['email'] ); self::assertSame( 'newsletter', $actual['data']['group'] ); + self::assertSame( + [ + 'source' => HCaptcha::get_class_source( NewsletterSubscribe::class ), + 'form_id' => 'newsletter-subscribe', + ], + $actual['expected_id'] + ); self::assertArrayNotHasKey( 'some_other_field', $actual['data'] ); } + /** + * Prepare hCaptcha widget id. + * + * @param array $id Widget id. + * + * @return void + * @noinspection PhpSameParameterValueInspection + */ + private function prepare_widget_id( array $id = [] ): void { + $id = array_merge( + [ + 'source' => HCaptcha::get_class_source( NewsletterSubscribe::class ), + 'form_id' => 'newsletter-subscribe', + ], + $id + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + /** * Test print_inline_styles(). * diff --git a/tests/php/integration/Blocksy/WaitlistTest.php b/tests/php/integration/Blocksy/WaitlistTest.php index 4ae89cdac..148555824 100644 --- a/tests/php/integration/Blocksy/WaitlistTest.php +++ b/tests/php/integration/Blocksy/WaitlistTest.php @@ -8,6 +8,7 @@ namespace HCaptcha\Tests\Integration\Blocksy; use HCaptcha\Blocksy\Waitlist; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use ReflectionException; use tad\FunctionMocker\FunctionMocker; @@ -52,6 +53,8 @@ public function test_constructor_and_init_hooks(): void { has_filter( 'blocksy:ext:woocommerce-extra:waitlist:subscribe:validate', [ $subject, 'verify' ] ) ); self::assertSame( 10, has_action( 'wp_head', [ $subject, 'print_inline_styles' ] ) ); + self::assertSame( 9, has_action( 'wp_print_footer_scripts', [ $subject, 'enqueue_scripts' ] ) ); + self::assertSame( 10, has_filter( 'script_loader_tag', [ $subject, 'add_type_module' ] ) ); } /** @@ -123,6 +126,7 @@ public function test_verify(): void { $email = 'test@example.com'; $this->prepare_verify_post( 'hcaptcha_blocksy_waitlist_nonce', 'hcaptcha_blocksy_waitlist' ); + $this->prepare_widget_id(); $_POST['product_id'] = $product_id; $_POST['email'] = $email; @@ -144,6 +148,7 @@ public function test_verify_not_verified(): void { $email = 'test@example.com'; $this->prepare_verify_post( 'hcaptcha_blocksy_waitlist_nonce', 'hcaptcha_blocksy_waitlist', false ); + $this->prepare_widget_id(); $_POST['product_id'] = $product_id; $_POST['email'] = $email; @@ -166,6 +171,7 @@ public function test_verify_with_existing_wp_error(): void { $existing = new WP_Error( 'some_error', 'Some error' ); $this->prepare_verify_post( 'hcaptcha_blocksy_waitlist_nonce', 'hcaptcha_blocksy_waitlist', false ); + $this->prepare_widget_id(); $_POST['product_id'] = $product_id; $_POST['email'] = $email; @@ -189,6 +195,7 @@ public function test_verify_with_non_standard_value(): void { $email = 'test@example.com'; $this->prepare_verify_post( 'hcaptcha_blocksy_waitlist_nonce', 'hcaptcha_blocksy_waitlist' ); + $this->prepare_widget_id(); $_POST['product_id'] = $product_id; $_POST['email'] = $email; @@ -201,6 +208,28 @@ public function test_verify_with_non_standard_value(): void { self::assertNull( $result ); } + /** + * Test verify() when widget id is missing. + * + * @return void + */ + public function test_verify_missing_widget_id(): void { + $product_id = 42; + $email = 'test@example.com'; + + $this->prepare_verify_post( 'hcaptcha_blocksy_waitlist_nonce', 'hcaptcha_blocksy_waitlist' ); + + $_POST['product_id'] = $product_id; + $_POST['email'] = $email; + + $subject = new Waitlist(); + + $result = $subject->verify( null, $product_id, $email ); + + self::assertInstanceOf( WP_Error::class, $result ); + self::assertSame( 'Bad hCaptcha signature!', $result->get_error_message( 'hcaptcha_error' ) ); + } + /** * Test get_entry(). * @@ -237,9 +266,74 @@ public function test_get_entry(): void { self::assertNotNull( $actual['form_date_gmt'] ); self::assertSame( 'test@example.com', $actual['data']['email'] ); self::assertSame( (string) $product_id, $actual['data']['product_id'] ); + self::assertSame( + [ + 'source' => HCaptcha::get_class_source( Waitlist::class ), + 'form_id' => 'waitlist', + ], + $actual['expected_id'] + ); self::assertArrayNotHasKey( 'some_other_field', $actual['data'] ); } + /** + * Prepare hCaptcha widget id. + * + * @param array $id Widget id. + * + * @return void + * @noinspection PhpSameParameterValueInspection + */ + private function prepare_widget_id( array $id = [] ): void { + $id = array_merge( + [ + 'source' => HCaptcha::get_class_source( Waitlist::class ), + 'form_id' => 'waitlist', + ], + $id + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + + /** + * Test add_type_module(). + * + * @return void + */ + public function test_add_type_module(): void { + $subject = new Waitlist(); + + // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript + $tag = ''; + + self::assertSame( $tag, $subject->add_type_module( $tag, 'other-handle', '' ) ); + self::assertStringContainsString( + 'type="module"', + $subject->add_type_module( $tag, 'hcaptcha-blocksy', '' ) + ); + } + + /** + * Test enqueue_scripts(). + * + * @return void + */ + public function test_enqueue_scripts(): void { + wp_dequeue_script( 'hcaptcha-blocksy' ); + hcaptcha()->form_shown = false; + + $subject = new Waitlist(); + + $subject->enqueue_scripts(); + self::assertFalse( wp_script_is( 'hcaptcha-blocksy' ) ); + + hcaptcha()->form_shown = true; + + $subject->enqueue_scripts(); + self::assertTrue( wp_script_is( 'hcaptcha-blocksy' ) ); + } + /** * Test print_inline_styles(). * diff --git a/tests/php/integration/BuddyPress/CreateGroupTest.php b/tests/php/integration/BuddyPress/CreateGroupTest.php index b7af9cb33..20bf87c78 100644 --- a/tests/php/integration/BuddyPress/CreateGroupTest.php +++ b/tests/php/integration/BuddyPress/CreateGroupTest.php @@ -14,6 +14,7 @@ use BP_Groups_Group; use HCaptcha\BuddyPress\CreateGroup; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use ReflectionException; use tad\FunctionMocker\FunctionMocker; @@ -83,6 +84,7 @@ static function ( $step_slug ) { $subject = new CreateGroup(); $this->prepare_verify_post( 'hcaptcha_bp_create_group_nonce', 'hcaptcha_bp_create_group' ); + $this->prepare_widget_id(); self::assertTrue( $subject->verify( new BP_Groups_Group() ) ); } @@ -117,6 +119,10 @@ public function test_get_entry(): void { 'description' => 'Some description', 'date_created' => '2026-02-15 15:03:24', ], + 'expected_id' => [ + 'source' => [ 'buddypress/bp-loader.php' ], + 'form_id' => 'create_group', + ], ], $actual ); @@ -148,10 +154,8 @@ static function ( $step_slug ) { ); FunctionMocker::replace( - 'defined', - static function ( $constant_name ) { - return 'BP_TESTS_DIR' === $constant_name; - } + 'HCaptcha\\BuddyPress\\bp_core_redirect', + static function (): void {} ); add_filter( @@ -168,6 +172,7 @@ static function ( $location, $status ) { $subject = new CreateGroup(); $this->prepare_verify_post( 'hcaptcha_bp_create_group_nonce', 'hcaptcha_bp_create_group', null ); + $this->prepare_widget_id(); self::assertFalse( $subject->verify( new BP_Groups_Group() ) ); @@ -213,4 +218,18 @@ static function ( $name ) { self::assertSame( $expected, ob_get_clean() ); } + + /** + * Prepare hCaptcha widget id. + * + * @return void + */ + private function prepare_widget_id(): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'buddypress/bp-loader.php' ], + 'form_id' => 'create_group', + ] + ); + } } diff --git a/tests/php/integration/BuddyPress/RegisterTest.php b/tests/php/integration/BuddyPress/RegisterTest.php index 7c1574652..3da5935c3 100644 --- a/tests/php/integration/BuddyPress/RegisterTest.php +++ b/tests/php/integration/BuddyPress/RegisterTest.php @@ -13,6 +13,7 @@ namespace HCaptcha\Tests\Integration\BuddyPress; use HCaptcha\BuddyPress\Register; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; /** @@ -64,7 +65,7 @@ public function test_add_captcha(): void { } /** - * Test add_captcha() with error. + * Test add_captcha() with an error. */ public function test_register_error(): void { global $bp; @@ -104,6 +105,7 @@ public function test_register_error(): void { */ public function test_verify(): void { $this->prepare_verify_post( 'hcaptcha_bp_register_nonce', 'hcaptcha_bp_register' ); + $this->prepare_widget_id(); $subject = new Register(); @@ -127,9 +129,24 @@ public function test_verify_not_verified(): void { $subject = new Register(); $this->prepare_verify_post( 'hcaptcha_bp_register_nonce', 'hcaptcha_bp_register', null ); + $this->prepare_widget_id(); self::assertFalse( $subject->verify() ); self::assertEquals( $expected, $bp->signup ); } + + /** + * Prepare hCaptcha widget id. + * + * @return void + */ + private function prepare_widget_id(): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'buddypress/bp-loader.php' ], + 'form_id' => 'register', + ] + ); + } } diff --git a/tests/php/integration/CLI/CommandsTest.php b/tests/php/integration/CLI/CommandsTest.php new file mode 100644 index 000000000..ead907340 --- /dev/null +++ b/tests/php/integration/CLI/CommandsTest.php @@ -0,0 +1,489 @@ +cleanup_temp_paths(); + + WP_CLI::reset(); + + parent::tearDown(); + } + + /** + * Test export() prints compact JSON without keys. + * + * @return void + */ + public function test_export_prints_json_without_keys(): void { + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => 'site-key', + 'secret_key' => 'secret-key', + 'theme' => 'dark', + 'size' => 'compact', + ] + ); + + $output = $this->capture_output( + static function (): void { + ( new Commands() )->export( [], [] ); + } + ); + $data = json_decode( $output, true ); + + self::assertSame( JSON_ERROR_NONE, json_last_error() ); + self::assertArrayHasKey( 'meta', $data ); + self::assertArrayHasKey( 'settings', $data ); + self::assertArrayNotHasKey( 'keys', $data ); + self::assertSame( 'dark', $data['settings']['theme'] ); + self::assertSame( 'compact', $data['settings']['size'] ); + self::assertArrayNotHasKey( 'site_key', $data['settings'] ); + self::assertArrayNotHasKey( 'secret_key', $data['settings'] ); + self::assertSame( [], WP_CLI::$success_messages ); + } + + /** + * Test export() writes pretty JSON with keys. + * + * @return void + */ + public function test_export_writes_file_with_keys(): void { + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => 'file-site-key', + 'secret_key' => 'file-secret-key', + 'theme' => 'light', + 'size' => 'normal', + ] + ); + + $file = $this->new_file_path_in_new_dir( 'settings.json' ); + $this->files[] = $file; + + ( new Commands() )->export( + [], + [ + 'include-keys' => true, + 'pretty' => true, + 'file' => $file, + ] + ); + + self::assertFileExists( $file ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $file ); + $data = json_decode( (string) $contents, true ); + + self::assertStringContainsString( "\n ", (string) $contents ); + self::assertSame( JSON_ERROR_NONE, json_last_error() ); + self::assertSame( 'file-site-key', $data['keys']['site_key'] ); + self::assertSame( 'file-secret-key', $data['keys']['secret_key'] ); + self::assertSame( [ sprintf( 'Exported settings to %s', $file ) ], WP_CLI::$success_messages ); + } + + /** + * Test export() reports a JSON encode error. + * + * @return void + */ + public function test_export_reports_json_encode_error(): void { + $settings = []; + $settings['recursive'] = &$settings; + + update_option( PluginSettingsBase::OPTION_NAME, $settings ); + + $this->expectException( WPCLIExitException::class ); + $this->expectExceptionMessage( 'Failed to encode JSON.' ); + + ( new Commands() )->export( [], [] ); + } + + /** + * Test export() reports directory creation failure. + * + * @return void + */ + public function test_export_reports_directory_creation_failure(): void { + $directory = $this->create_temp_file( 'not a directory' ); + $file = $directory . '/settings.json'; + + $this->expectException( WPCLIExitException::class ); + $this->expectExceptionMessage( sprintf( 'Cannot create directory: %s', $directory ) ); + + ( new Commands() )->export( [], [ 'file' => $file ] ); + } + + /** + * Test export() reports file write failure. + * + * @return void + */ + public function test_export_reports_file_write_failure(): void { + $directory = $this->create_temp_dir(); + + $this->expectException( WPCLIExitException::class ); + $this->expectExceptionMessage( sprintf( 'Cannot write file: %s', $directory ) ); + + ( new Commands() )->export( [], [ 'file' => $directory ] ); + } + + /** + * Test import() reports missing file argument. + * + * @return void + */ + public function test_import_reports_missing_file_argument(): void { + $this->expectException( WPCLIExitException::class ); + $this->expectExceptionMessage( 'Missing argument.' ); + + ( new Commands() )->import( [], [] ); + } + + /** + * Test import() reports missing or unreadable file. + * + * @return void + */ + public function test_import_reports_missing_file(): void { + $file = wp_normalize_path( sys_get_temp_dir() ) . '/' . uniqid( 'missing-hcap-cli-', true ) . '.json'; + + $this->expectException( WPCLIExitException::class ); + $this->expectExceptionMessage( sprintf( 'File not found or unreadable: %s', $file ) ); + + ( new Commands() )->import( [ $file ], [] ); + } + + /** + * Test import() reports invalid JSON. + * + * @return void + */ + public function test_import_reports_invalid_json(): void { + $file = $this->create_temp_file( '{invalid json' ); + + json_decode( '{invalid json' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_decode_json_decode -- Set decoder error state for CLI validation. + + $this->expectException( WPCLIExitException::class ); + $this->expectExceptionMessage( 'Invalid JSON.' ); + + ( new Commands() )->import( [ $file ], [] ); + } + + /** + * Test import() validates a payload in dry-run mode. + * + * @return void + */ + public function test_import_dry_run_reports_payload_summary(): void { + $payload = $this->build_export_payload( + [ + 'site_key' => 'dry-site', + 'secret_key' => 'dry-secret', + 'theme' => 'dark', + 'size' => 'compact', + ], + true + ); + + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => 'old-site', + 'secret_key' => 'old-secret', + 'theme' => 'light', + 'size' => 'normal', + ] + ); + + $file = $this->create_json_file( $payload ); + + ( new Commands() )->import( + [ $file ], + [ + 'dry-run' => true, + 'allow-keys' => true, + ] + ); + + self::assertSame( + [ + 'Dry run completed. Detected 2 settings fields. Keys present: yes. Keys would be applied: yes.', + ], + WP_CLI::$success_messages + ); + + $saved = get_option( PluginSettingsBase::OPTION_NAME, [] ); + + self::assertSame( 'light', $saved['theme'] ?? '' ); + self::assertSame( 'normal', $saved['size'] ?? '' ); + self::assertSame( 'old-site', $saved['site_key'] ?? '' ); + self::assertSame( 'old-secret', $saved['secret_key'] ?? '' ); + } + + /** + * Test import() reports dry-run validation errors. + * + * @return void + */ + public function test_import_dry_run_reports_validation_error(): void { + $file = $this->create_json_file( $this->invalid_payload() ); + + $this->expectException( WPCLIExitException::class ); + $this->expectExceptionMessage( 'Unsupported settings format.' ); + + ( new Commands() )->import( [ $file ], [ 'dry-run' => true ] ); + } + + /** + * Test import() reports apply validation errors. + * + * @return void + */ + public function test_import_reports_apply_validation_error(): void { + $file = $this->create_json_file( $this->invalid_payload() ); + + $this->expectException( WPCLIExitException::class ); + $this->expectExceptionMessage( 'Unsupported settings format.' ); + + ( new Commands() )->import( [ $file ], [] ); + } + + /** + * Test import() applies settings and skips keys by default. + * + * @return void + */ + public function test_import_applies_settings_and_warns_when_keys_are_skipped(): void { + $payload = $this->build_export_payload( + [ + 'site_key' => 'skipped-site', + 'secret_key' => 'skipped-secret', + 'theme' => 'dark', + 'size' => 'compact', + ], + true + ); + + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'site_key' => 'kept-site', + 'secret_key' => 'kept-secret', + 'theme' => 'light', + 'size' => 'normal', + ] + ); + + $file = $this->create_json_file( $payload ); + + ( new Commands() )->import( [ $file ], [] ); + + $saved = get_option( PluginSettingsBase::OPTION_NAME, [] ); + + self::assertSame( 'dark', $saved['theme'] ?? '' ); + self::assertSame( 'compact', $saved['size'] ?? '' ); + self::assertNotSame( 'skipped-site', $saved['site_key'] ?? '' ); + self::assertNotSame( 'skipped-secret', $saved['secret_key'] ?? '' ); + self::assertSame( + [ 'Keys present in JSON were skipped. Use --allow-keys to import.' ], + WP_CLI::$warning_messages + ); + self::assertSame( + [ 'hCaptcha settings were successfully imported.' ], + WP_CLI::$success_messages + ); + } + + /** + * Build an export payload from settings. + * + * @param array $settings Settings. + * @param bool $include_keys Whether to include keys. + * + * @return array + */ + private function build_export_payload( array $settings, bool $include_keys ): array { + update_option( PluginSettingsBase::OPTION_NAME, $settings ); + + return ( new SettingsTransfer() )->build_export_payload( $include_keys ); + } + + /** + * Get an invalid import payload. + * + * @return array + */ + private function invalid_payload(): array { + return [ + 'meta' => [ + 'plugin' => 'Wrong Plugin', + 'schema_version' => '0.0', + ], + 'settings' => [], + ]; + } + + /** + * Create a temporary JSON file. + * + * @param array $payload Payload. + * + * @return string + */ + private function create_json_file( array $payload ): string { + return $this->create_temp_file( (string) wp_json_encode( $payload ) ); + } + + /** + * Create a temporary file. + * + * @param string $contents File contents. + * + * @return string + */ + private function create_temp_file( string $contents ): string { + $file = tempnam( sys_get_temp_dir(), 'hcap-cli-' ); + + self::assertIsString( $file ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $file, $contents ); + + $this->files[] = $file; + + return $file; + } + + /** + * Create a file path in a missing temporary directory. + * + * @param string $filename File name. + * + * @return string + */ + private function new_file_path_in_new_dir( string $filename ): string { + $directory = wp_normalize_path( sys_get_temp_dir() ) . '/' . uniqid( 'hcap-cli-', true ); + $this->dirs[] = $directory; + + return $directory . '/' . $filename; + } + + /** + * Create a temporary directory. + * + * @return string + */ + private function create_temp_dir(): string { + $directory = wp_normalize_path( sys_get_temp_dir() ) . '/' . uniqid( 'hcap-cli-', true ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + self::assertTrue( mkdir( $directory, 0777, true ) ); + + $this->dirs[] = $directory; + + return $directory; + } + + /** + * Capture echoed output. + * + * @param callable $callback Callback. + * + * @return string + * @throws Throwable Throwable. + */ + private function capture_output( callable $callback ): string { + ob_start(); + + try { + $callback(); + + return (string) ob_get_clean(); + } catch ( Throwable $throwable ) { + ob_end_clean(); + + throw $throwable; + } + } + + /** + * Remove temporary files and directories. + * + * @return void + */ + private function cleanup_temp_paths(): void { + foreach ( $this->files as $file ) { + if ( is_file( $file ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $file ); + } + } + + foreach ( array_reverse( $this->dirs ) as $directory ) { + if ( is_dir( $directory ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + rmdir( $directory ); + } + } + + $this->files = []; + $this->dirs = []; + } +} diff --git a/tests/php/integration/CLI/WPCLI.php b/tests/php/integration/CLI/WPCLI.php new file mode 100644 index 000000000..e33a26865 --- /dev/null +++ b/tests/php/integration/CLI/WPCLI.php @@ -0,0 +1,104 @@ +prepare_verify_post( 'hcaptcha_coblocks_nonce', 'hcaptcha_coblocks' ); + $_POST['form-hash'] = $form_id; + $this->prepare_widget_id( $form_id ); // Add form field data to cover foreach in get_entry(). $_POST['field-name'] = [ @@ -268,6 +272,7 @@ public function test_verify(): void { * @return void */ public function test_verify_not_verified(): void { + $form_id = '5509c11f003ddee1ee47fbb0a2ffb7b47b57434c'; $verify_url = CoBlocks_Form::GCAPTCHA_VERIFY_URL; $response = [ 'some response' ]; $parsed_args = [ @@ -286,6 +291,8 @@ public function test_verify_not_verified(): void { ]; $this->prepare_verify_post( 'hcaptcha_coblocks_nonce', 'hcaptcha_coblocks', false ); + $_POST['form-hash'] = $form_id; + $this->prepare_widget_id( $form_id ); $subject = new Form(); @@ -298,6 +305,73 @@ public function test_verify_not_verified(): void { self::assertFalse( has_filter( 'pre_http_request', [ $subject, 'verify' ] ) ); } + /** + * Test verify() when widget id is missing. + * + * @return void + */ + public function test_verify_missing_widget_id(): void { + $form_id = '5509c11f003ddee1ee47fbb0a2ffb7b47b57434c'; + $verify_url = CoBlocks_Form::GCAPTCHA_VERIFY_URL; + $response = [ 'some response' ]; + $parsed_args = [ + 'body' => [ + 'response' => 'hcaptcha_token', + ], + ]; + $url = $verify_url; + $expected = [ + 'body' => '{"success":false}', + 'response' => + [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + + $this->prepare_verify_post( 'hcaptcha_coblocks_nonce', 'hcaptcha_coblocks' ); + $_POST['form-hash'] = $form_id; + + $subject = new Form(); + + add_filter( 'pre_http_request', [ $subject, 'verify' ] ); + + self::assertSame( $expected, $subject->verify( $response, $parsed_args, $url ) ); + self::assertFalse( has_filter( 'pre_http_request', [ $subject, 'verify' ] ) ); + + $block = [ + 'blockName' => 'coblocks/form', + ]; + $instance = Mockery::mock( WP_Block::class ); + $content = '
    '; + + self::assertStringContainsString( + '
    Bad hCaptcha signature!
    ', + $subject->add_hcaptcha( $content, $block, $instance ) + ); + } + + /** + * Prepare hCaptcha widget id. + * + * @param int|string $form_id Form id. + * @param array $id Widget id. + * + * @return void + * @noinspection PhpSameParameterValueInspection + */ + private function prepare_widget_id( $form_id, array $id = [] ): void { + $id = array_merge( + [ + 'source' => [ 'coblocks/class-coblocks.php' ], + 'form_id' => $form_id, + ], + $id + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + /** * Test print_inline_styles(). * diff --git a/tests/php/integration/DelayedScript/DelayedScriptTest.php b/tests/php/integration/DelayedScript/DelayedScriptTest.php index 0c7b71495..c321747c1 100644 --- a/tests/php/integration/DelayedScript/DelayedScriptTest.php +++ b/tests/php/integration/DelayedScript/DelayedScriptTest.php @@ -27,6 +27,8 @@ class DelayedScriptTest extends HCaptchaWPTestCase { * @throws ReflectionException ReflectionException. */ public function tearDown(): void { + remove_all_filters( 'hcap_delay_api_selector' ); + $prop = ( new ReflectionClass( DelayedScript::class ) )->getProperty( 'launched' ); $prop->setAccessible( true ); $prop->setValue( null, [] ); @@ -121,6 +123,122 @@ function scrollHandler() { self::assertSame( $expected, DelayedScript::create( $js, - 1 ) ); } + /** + * Test create() with a custom delay API event. + * + * @noinspection BadExpressionStatementJS + * @noinspection JSUnusedLocalSymbols + */ + public function test_create_with_delay_api_event(): void { + FunctionMocker::replace( + 'defined', + static function ( $constant_name ) { + return 'SCRIPT_DEBUG' === $constant_name; + } + ); + + FunctionMocker::replace( + 'constant', + static function ( $name ) { + return 'SCRIPT_DEBUG' === $name; + } + ); + + $js = "\t\t\tconst some = 1;"; + $delay_api_event = 'hcap:load-api'; + + $expected = <<<'JS' + ( () => { + 'use strict'; + + // noinspection JSAnnotator + const delayApiEvent = "hcap:load-api"; + let loaded = false; + + function load() { + if ( loaded ) { + return; + } + + loaded = true; + document.removeEventListener( delayApiEvent, load ); + + const some = 1; + } + + document.addEventListener( 'hCaptchaBeforeAPI', function() { + document.addEventListener( delayApiEvent, load ); + } ); + } )(); +JS; + + $expected = "\n"; + $output = DelayedScript::create( $js, 3000, $delay_api_event ); + + self::assertSame( $expected, $output ); + self::assertStringNotContainsString( 'setTimeout', $output ); + self::assertStringNotContainsString( 'touchstart', $output ); + self::assertStringNotContainsString( 'mouseenter', $output ); + self::assertStringNotContainsString( 'keydown', $output ); + self::assertStringNotContainsString( 'scrollHandler', $output ); + } + + /** + * Test enqueue() without a delay API selector. + * + * @return void + */ + public function test_enqueue_without_delay_api_selector(): void { + $handle = 'hcap-delayed-script-enqueue'; + + wp_register_script( $handle, 'https://example.com/enqueue.js', [], '1.0', false ); + + DelayedScript::enqueue( $handle ); + + self::assertTrue( wp_script_is( $handle ) ); + } + + /** + * Test enqueue() with a delay API selector. + * + * @return void + */ + public function test_enqueue_with_delay_api_selector(): void { + $handle = 'hcap-delayed-script-observe'; + + add_filter( + 'hcap_delay_api_selector', + static function (): string { + return ' .delayed-form '; + } + ); + + wp_register_script( $handle, 'https://example.com/observe.js', [], '1.0', false ); + + DelayedScript::enqueue( $handle ); + + self::assertFalse( wp_script_is( $handle ) ); + + ob_start(); + DelayedScript::print_observation_script(); + $output = (string) ob_get_clean(); + + self::assertStringContainsString( 'hCaptchaObserve( ".delayed-form", ', $output ); + self::assertStringContainsString( 'https:\/\/example.com\/observe.js?ver=1.0', $output ); + } + + /** + * Test print_observation_script() without launched scripts. + * + * @return void + */ + public function test_print_observation_script_without_launched_scripts(): void { + ob_start(); + DelayedScript::print_observation_script(); + $output = (string) ob_get_clean(); + + self::assertSame( '', $output ); + } /** * Test observe(). * @@ -182,6 +300,50 @@ public function test_observe(): void { self::assertStringContainsString( 'hCaptchaObserve( ".my-form", ' . $sources_js . ' );', $output ); } + /** + * Test observe() ignores handles without a source. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_observe_ignores_handles_without_source(): void { + $method = new ReflectionMethod( DelayedScript::class, 'observe' ); + $method->setAccessible( true ); + + $method->invoke( null, '.missing-script-form', 'missing-script' ); + + self::assertSame( [], $this->get_launched() ); + } + + /** + * Test print_observation_script() skips scripts that cannot be printed. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_print_observation_script_skips_unprintable_scripts(): void { + $handle = 'hcap-delayed-script-enqueued'; + + wp_register_script( $handle, 'https://example.com/enqueued.js', [], '1.0', false ); + wp_enqueue_script( $handle ); + + $this->set_launched( + [ + '.skip-form' => [ + $handle, + 'missing-script', + ], + ] + ); + + ob_start(); + DelayedScript::print_observation_script(); + $output = (string) ob_get_clean(); + + self::assertStringContainsString( 'hCaptchaObserve = function', $output ); + self::assertStringNotContainsString( '".skip-form"', $output ); + self::assertStringNotContainsString( 'https:\/\/example.com\/enqueued.js?ver=1.0', $output ); + } /** * Test launch(). * @@ -277,4 +439,135 @@ function scrollHandler() { DelayedScript::launch( $args, - 1 ); self::assertSame( $expected, ob_get_clean() ); } + + /** + * Test launch() with data attributes. + * + * @noinspection BadExpressionStatementJS + */ + public function test_launch_with_data_attributes(): void { + FunctionMocker::replace( + 'defined', + static function ( $constant_name ) { + return 'SCRIPT_DEBUG' === $constant_name; + } + ); + + FunctionMocker::replace( + 'constant', + static function ( $name ) { + return 'SCRIPT_DEBUG' === $name; + } + ); + + ob_start(); + DelayedScript::launch( + [ + 'src' => 'https://js.hcaptcha.com/1/api.js', + 'id' => 'ignored-id', + 'async' => false, + 'data' => [ + 'sitekey' => 'site-key', + 'theme' => 'dark', + ], + ] + ); + $output = (string) ob_get_clean(); + + self::assertStringContainsString( "s.setAttribute( 'data-' + 'sitekey', 'site-key' );", $output ); + self::assertStringContainsString( "s.setAttribute( 'data-' + 'theme', 'dark' );", $output ); + self::assertStringContainsString( "s['src'] = 'https://js.hcaptcha.com/1/api.js';", $output ); + self::assertStringNotContainsString( 'ignored-id', $output ); + self::assertStringNotContainsString( "s['async']", $output ); + } + /** + * Test launch() with a custom delay API event. + * + * @noinspection BadExpressionStatementJS + */ + public function test_launch_with_delay_api_event(): void { + FunctionMocker::replace( + 'defined', + static function ( $constant_name ) { + return 'SCRIPT_DEBUG' === $constant_name; + } + ); + + FunctionMocker::replace( + 'constant', + static function ( $name ) { + return 'SCRIPT_DEBUG' === $name; + } + ); + + $expected = <<<'JS' + ( () => { + 'use strict'; + + // noinspection JSAnnotator + const delayApiEvent = "hcap:load-api"; + let loaded = false; + + function load() { + if ( loaded ) { + return; + } + + loaded = true; + document.removeEventListener( delayApiEvent, load ); + + const t = document.getElementsByTagName( 'script' )[0]; + const s = document.createElement( 'script' ); + s.type = 'text/javascript'; + s.id = 'hcaptcha-api'; + s['src'] = 'https://js.hcaptcha.com/1/api.js'; + s.async = true; + t.parentNode.insertBefore( s, t ); + } + + document.addEventListener( 'hCaptchaBeforeAPI', function() { + document.addEventListener( delayApiEvent, load ); + } ); + } )(); +JS; + + $expected = "\n"; + + ob_start(); + DelayedScript::launch( [ 'src' => 'https://js.hcaptcha.com/1/api.js' ], 3000, 'hcap:load-api' ); + $output = ob_get_clean(); + + self::assertSame( $expected, $output ); + self::assertStringNotContainsString( 'setTimeout', $output ); + self::assertStringNotContainsString( 'touchstart', $output ); + self::assertStringNotContainsString( 'mouseenter', $output ); + self::assertStringNotContainsString( 'keydown', $output ); + self::assertStringNotContainsString( 'scrollHandler', $output ); + } + /** + * Get launched scripts. + * + * @return array + * @throws ReflectionException ReflectionException. + */ + private function get_launched(): array { + $prop = ( new ReflectionClass( DelayedScript::class ) )->getProperty( 'launched' ); + $prop->setAccessible( true ); + + return (array) $prop->getValue(); + } + + /** + * Set launched scripts. + * + * @param array $launched Launched scripts. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + private function set_launched( array $launched ): void { + $prop = ( new ReflectionClass( DelayedScript::class ) )->getProperty( 'launched' ); + $prop->setAccessible( true ); + $prop->setValue( null, $launched ); + } } diff --git a/tests/php/integration/DownloadManager/DownloadManagerTest.php b/tests/php/integration/DownloadManager/DownloadManagerTest.php index 2596e942c..e15b86b64 100644 --- a/tests/php/integration/DownloadManager/DownloadManagerTest.php +++ b/tests/php/integration/DownloadManager/DownloadManagerTest.php @@ -13,6 +13,7 @@ namespace HCaptcha\Tests\Integration\DownloadManager; use HCaptcha\DownloadManager\DownloadManager; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use tad\FunctionMocker\FunctionMocker; @@ -158,7 +159,12 @@ public function test_add_hcaptcha(): void { * Test verify(). */ public function test_verify(): void { + $form_id = 3220; + $this->prepare_verify_post( 'hcaptcha_download_manager_nonce', 'hcaptcha_download_manager' ); + $this->prepare_widget_id( $form_id ); + + $_GET['wpdmdl'] = $form_id; $subject = new DownloadManager(); @@ -180,7 +186,48 @@ public function test_verify_not_verified(): void { ], ]; + $form_id = 3220; + $this->prepare_verify_post( 'hcaptcha_download_manager_nonce', 'hcaptcha_download_manager', false ); + $this->prepare_widget_id( $form_id ); + + $_GET['wpdmdl'] = $form_id; + + $subject = new DownloadManager(); + + add_filter( + 'wp_die_handler', + static function ( $name ) use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + $subject->verify( null ); + + self::assertSame( $expected, $die_arr ); + } + + /** + * Test verify() with missing hCaptcha widget id. + * + * @noinspection PhpUnusedParameterInspection + */ + public function test_verify_missing_widget_id(): void { + $die_arr = []; + $expected = [ + 'Bad hCaptcha signature!', + 'hCaptcha error', + [ + 'back_link' => true, + 'response' => 303, + ], + ]; + + $_GET['wpdmdl'] = 3220; + + $this->prepare_verify_post( 'hcaptcha_download_manager_nonce', 'hcaptcha_download_manager' ); $subject = new DownloadManager(); @@ -198,6 +245,22 @@ static function ( $name ) use ( &$die_arr ) { self::assertSame( $expected, $die_arr ); } + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * + * @return void + */ + private function prepare_widget_id( int $form_id ): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'download-manager/download-manager.php' ], + 'form_id' => $form_id, + ] + ); + } + /** * Test print_inline_styles(). * diff --git a/tests/php/integration/EssentialBlocks/FormTest.php b/tests/php/integration/EssentialBlocks/FormTest.php index 51c97785b..130c20bde 100644 --- a/tests/php/integration/EssentialBlocks/FormTest.php +++ b/tests/php/integration/EssentialBlocks/FormTest.php @@ -8,6 +8,7 @@ namespace HCaptcha\Tests\Integration\EssentialBlocks; use HCaptcha\EssentialBlocks\Form; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use Mockery; use ReflectionException; @@ -83,7 +84,7 @@ public function test_add_hcaptcha( string $content, array $block, string $expect 'name' => 'hcaptcha_essential_blocks_nonce', 'id' => [ 'source' => [ 'essential-blocks/essential-blocks.php' ], - 'form_id' => 'ebf-faf933', + 'form_id' => 'eb-form-xmouf', ], ]; @@ -108,9 +109,9 @@ public function dp_test_add_hcaptcha(): array { '
    ', ], 'Essential Blocks Form' => [ - '
    Send', [ 'blockName' => 'essential-blocks/form' ], - '
    {hcaptcha}
    {hcaptcha}
    Send', ], ]; } @@ -123,13 +124,17 @@ public function dp_test_add_hcaptcha(): array { public function test_verify(): void { $this->prepare_verify_post( 'hcaptcha_essential_blocks_nonce', 'hcaptcha_essential_blocks' ); + $form_id = 'eb-form-xmouf'; + // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash $form_data['h-captcha-response'] = $_POST['h-captcha-response']; $form_data['hcaptcha_essential_blocks_nonce'] = $_POST['hcaptcha_essential_blocks_nonce']; + $form_data[ HCaptcha::HCAPTCHA_WIDGET_ID ] = $this->get_widget_id( $form_id ); // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash unset( $_POST['h-captcha-response'], $_POST['hcaptcha_essential_blocks_nonce'] ); + $_POST['form_id'] = $form_id; $_POST['form_data'] = wp_json_encode( $form_data ); $subject = new Form(); @@ -152,13 +157,17 @@ public function test_verify_not_verified(): void { $this->prepare_verify_post( 'hcaptcha_essential_blocks_nonce', 'hcaptcha_essential_blocks', false ); + $form_id = 'eb-form-xmouf'; + // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash $form_data['h-captcha-response'] = $_POST['h-captcha-response']; $form_data['hcaptcha_essential_blocks_nonce'] = $_POST['hcaptcha_essential_blocks_nonce']; + $form_data[ HCaptcha::HCAPTCHA_WIDGET_ID ] = $this->get_widget_id( $form_id ); // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash unset( $_POST['h-captcha-response'], $_POST['hcaptcha_essential_blocks_nonce'] ); + $_POST['form_id'] = $form_id; $_POST['form_data'] = wp_json_encode( $form_data ); add_filter( 'wp_doing_ajax', '__return_true' ); @@ -206,7 +215,7 @@ public function test_get_entry(): void { 'hcap_hp_sig' => 'sig', ]; - $actual = $method->invoke( $subject, $form_data ); + $actual = $method->invoke( $subject, $form_data, 'eb-form-xmouf' ); self::assertSame( [ @@ -221,6 +230,10 @@ public function test_get_entry(): void { 'email' => 'your.email+fakedata80629@gmail.com', 'message' => 'Message', ], + 'expected_id' => [ + 'source' => [ 'essential-blocks/essential-blocks.php' ], + 'form_id' => 'eb-form-xmouf', + ], ], $actual ); @@ -293,4 +306,20 @@ public function test_enqueue_scripts(): void { self::assertTrue( wp_script_is( 'hcaptcha-essential-blocks' ) ); } + + /** + * Get hCaptcha widget id. + * + * @param string $form_id Form id. + * + * @return string + */ + private function get_widget_id( string $form_id ): string { + return HCaptcha::widget_id_value( + [ + 'source' => [ 'essential-blocks/essential-blocks.php' ], + 'form_id' => $form_id, + ] + ); + } } diff --git a/tests/php/integration/FluentForm/FormTest.php b/tests/php/integration/FluentForm/FormTest.php index 8bef93fee..95ef20a0d 100644 --- a/tests/php/integration/FluentForm/FormTest.php +++ b/tests/php/integration/FluentForm/FormTest.php @@ -212,13 +212,15 @@ public function test_verify(): void { 'some_error' => 'Some error description', ]; $response = 'some response'; - $widget_id = 'some-widget-id'; + $form_id = 1; + $widget_id = $this->get_widget_id( $form_id ); $data = [ 'h-captcha-response' => $response, 'hcaptcha-widget-id' => $widget_id, ]; $fields = []; $attributes = [ + 'id' => $form_id, // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode 'form_fields' => json_encode( [ @@ -287,6 +289,8 @@ public function test_verify(): void { ]; $form = Mockery::mock( FluentForm::class ); + $form->shouldReceive( '__isset' )->with( 'id' )->andReturn( true ); + $form->shouldReceive( '__get' )->with( 'id' )->andReturn( $form_id ); $form->shouldReceive( 'getAttributes' )->with()->andReturn( $attributes ); $mock = Mockery::mock( Form::class )->makePartial(); @@ -324,7 +328,8 @@ public function test_verify_not_verified(): void { 'some_error' => 'Some error description', ]; $response = 'some response'; - $widget_id = 'some-widget-id'; + $form_id = 1; + $widget_id = $this->get_widget_id( $form_id ); $data = [ 'h-captcha-response' => $response, 'hcaptcha-widget-id' => $widget_id, @@ -335,6 +340,7 @@ public function test_verify_not_verified(): void { ]; $fields = []; $attributes = [ + 'id' => $form_id, // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode 'form_fields' => json_encode( [ @@ -345,6 +351,8 @@ public function test_verify_not_verified(): void { ]; $form = Mockery::mock( FluentForm::class ); + $form->shouldReceive( '__isset' )->with( 'id' )->andReturn( true ); + $form->shouldReceive( '__get' )->with( 'id' )->andReturn( $form_id ); $form->shouldReceive( 'getAttributes' )->with()->andReturn( $attributes ); $mock = Mockery::mock( Form::class )->makePartial(); @@ -367,6 +375,60 @@ public function test_verify_not_verified(): void { self::assertSame( $expected, $mock->verify( $errors, $data, $form, $fields ) ); } + /** + * Test verify() when widget id is missing. + * + * @return void + * @noinspection JsonEncodingApiUsageInspection + */ + public function test_verify_missing_widget_id(): void { + $errors = [ + 'some_error' => 'Some error description', + ]; + $response = 'some response'; + $form_id = 1; + $data = [ + 'h-captcha-response' => $response, + ]; + $expected = [ + 'some_error' => 'Some error description', + 'h-captcha-response' => [ 'Bad hCaptcha signature!' ], + ]; + $fields = []; + $attributes = [ + 'id' => $form_id, + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + 'form_fields' => json_encode( + [ + 'fields' => [], + 'submitButton' => [], + ] + ), + ]; + + $form = Mockery::mock( FluentForm::class ); + $form->shouldReceive( '__isset' )->with( 'id' )->andReturn( true ); + $form->shouldReceive( '__get' )->with( 'id' )->andReturn( $form_id ); + $form->shouldReceive( 'getAttributes' )->with()->andReturn( $attributes ); + + $mock = Mockery::mock( Form::class )->makePartial(); + $mock->shouldAllowMockingProtectedMethods(); + + $this->prepare_verify_request( $response ); + + //phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $post_data = [ + 'h-captcha-response' => $response, + 'hcap_hp_sig' => $_POST['hcap_hp_sig'], + 'hcap_fst_token' => $_POST['hcap_fst_token'], + 'hcap_hp_test' => $_POST['hcap_hp_test'], + ]; + //phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + + $_POST['data'] = http_build_query( $post_data ); + + self::assertSame( $expected, $mock->verify( $errors, $data, $form, $fields ) ); + } /** * Test verify() when a multistep form is not verified. * @@ -379,7 +441,8 @@ public function test_verify_multi_step_not_verified(): void { 'some_error' => 'Some error description', ]; $response = 'some response'; - $widget_id = 'some-widget-id'; + $form_id = 1; + $widget_id = $this->get_widget_id( $form_id ); $data = [ 'h-captcha-response' => $response, 'hcaptcha-widget-id' => $widget_id, @@ -405,6 +468,7 @@ public function test_verify_multi_step_not_verified(): void { ], ]; $attributes = [ + 'id' => $form_id, // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode 'form_fields' => json_encode( [ @@ -416,6 +480,8 @@ public function test_verify_multi_step_not_verified(): void { ]; $form = Mockery::mock( FluentForm::class ); + $form->shouldReceive( '__isset' )->with( 'id' )->andReturn( true ); + $form->shouldReceive( '__get' )->with( 'id' )->andReturn( $form_id ); $form->shouldReceive( 'getAttributes' )->with()->andReturn( $attributes ); $mock = Mockery::mock( Form::class )->makePartial(); @@ -467,7 +533,8 @@ public function test_verify_login_form( bool $password_ok, bool $is_login_limit_ 'some_error' => 'Some error description', ]; $response = 'some response'; - $widget_id = 'some-widget-id'; + $form_id = 1; + $widget_id = $this->get_widget_id( $form_id ); $user = get_user_by( 'id', 1 ); $email = $user->user_email; $password = 'some password'; @@ -477,6 +544,7 @@ public function test_verify_login_form( bool $password_ok, bool $is_login_limit_ ]; $fields = []; $attributes = [ + 'id' => $form_id, // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode 'form_fields' => json_encode( [ @@ -487,6 +555,8 @@ public function test_verify_login_form( bool $password_ok, bool $is_login_limit_ ]; $form = Mockery::mock( FluentForm::class ); + $form->shouldReceive( '__isset' )->with( 'id' )->andReturn( true ); + $form->shouldReceive( '__get' )->with( 'id' )->andReturn( $form_id ); $form->shouldReceive( 'getAttributes' )->with()->andReturn( $attributes ); $array_helper = Mockery::mock( 'alias:' . ArrayHelper::class ); @@ -1033,6 +1103,22 @@ private function get_get_hcaptcha_wrapped( int $form_id ): string { return ob_get_clean(); } + /** + * Get widget id. + * + * @param int $form_id Form id. + * @param array $id Widget id. + * + * @return string + */ + private function get_widget_id( int $form_id, array $id = [] ): string { + $id = $id ?: [ + 'source' => [ 'fluentformpro/fluentformpro.php', 'fluentform/fluentform.php' ], + 'form_id' => $form_id, + ]; + + return HCaptcha::widget_id_value( $id ); + } /** * Get hCaptcha form. * diff --git a/tests/php/integration/Formidable/FormTest.php b/tests/php/integration/Formidable/FormTest.php index 3347d9382..05a7e0834 100644 --- a/tests/php/integration/Formidable/FormTest.php +++ b/tests/php/integration/Formidable/FormTest.php @@ -315,6 +315,39 @@ public function test_verify(): void { self::assertSame( $errors, $subject->verify( $errors, $values, $validate_args ) ); } + /** + * Test verify() when widget id is missing. + * + * @return void + */ + public function test_verify_missing_widget_id(): void { + $form_id = 5; + $errors = []; + $values = [ + 'h-captcha-response' => 'some response', + 'form_id' => $form_id, + ]; + $validate_args = []; + $expected = [ + 'field1' => 'Bad hCaptcha signature!', + ]; + + $this->prepare_verify_post( + 'hcaptcha_formidable_forms_nonce', + 'hcaptcha_formidable_forms' + ); + + 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 ) ); + } /** * Test enqueue_scripts(). * diff --git a/tests/php/integration/Forminator/FormTest.php b/tests/php/integration/Forminator/FormTest.php index f308cc5e3..73df3fe8e 100644 --- a/tests/php/integration/Forminator/FormTest.php +++ b/tests/php/integration/Forminator/FormTest.php @@ -149,6 +149,7 @@ public function test_verify_no_success(): void { 'hcaptcha_forminator', false ); + $this->prepare_widget_id( $id ); Forminator_Front_Action::$module_object = $module_object; @@ -207,6 +208,7 @@ public function test_verify(): void { 'hcaptcha_forminator_nonce', 'hcaptcha_forminator' ); + $this->prepare_widget_id( $id ); Forminator_Front_Action::$module_object = $module_object; @@ -216,6 +218,37 @@ public function test_verify(): void { self::assertEquals( (object) [ 'fields' => $expected_fields ], $module_object ); } + /** + * Test verify() when widget id is missing. + * + * @return void + */ + public function test_verify_missing_widget_id(): void { + $id = 5; + $form_settings = [ 'some form settings' ]; + $error_message = 'Bad hCaptcha signature!'; + $module_object = (object) [ + 'fields' => [], + ]; + $expected = [ + 'can_submit' => false, + 'error' => $error_message, + ]; + + $this->prepare_verify_post( + 'hcaptcha_forminator_nonce', + 'hcaptcha_forminator' + ); + + unset( $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] ); + + Forminator_Front_Action::$module_object = $module_object; + + $subject = new Form(); + + self::assertSame( $expected, $subject->verify( true, $id, $form_settings ) ); + } + /** * Test print_hcaptcha_scripts(). * @@ -380,6 +413,23 @@ public function test_replace_hcaptcha_field(): void { self::assertSame( $hcap_form, $subject->replace_hcaptcha_field( $html, $hcaptcha_field, $front_instance ) ); } + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * @param array $id Widget id. + * + * @return void + */ + private function prepare_widget_id( int $form_id, array $id = [] ): void { + $id = $id ?: [ + 'source' => [ 'forminator/forminator.php' ], + 'form_id' => $form_id, + ]; + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + /** * Test is_forminator_admin_page(). * diff --git a/tests/php/integration/GiveWP/BaseTest.php b/tests/php/integration/GiveWP/BaseTest.php index 40f303f2f..6f8f278b5 100644 --- a/tests/php/integration/GiveWP/BaseTest.php +++ b/tests/php/integration/GiveWP/BaseTest.php @@ -13,11 +13,13 @@ namespace HCaptcha\Tests\Integration\GiveWP; 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 WP_Error; /** * Test Base class (via Form). @@ -137,9 +139,13 @@ public function test_add_captcha(): void { * @return void */ public function test_verify(): void { + $form_id = 42; + $this->prepare_verify_post( 'hcaptcha_give_wp_form_nonce', 'hcaptcha_give_wp_form' ); + $this->prepare_widget_id( $form_id ); - $_POST['action'] = 'give_process_donation'; + $_POST['action'] = 'give_process_donation'; + $_POST['give-form-id'] = $form_id; FunctionMocker::replace( 'give_set_error' ); @@ -158,9 +164,13 @@ public function test_verify(): void { * @return void */ public function test_verify_not_verified(): void { + $form_id = 42; + $this->prepare_verify_post( 'hcaptcha_give_wp_form_nonce', 'hcaptcha_give_wp_form', false ); + $this->prepare_widget_id( $form_id ); - $_POST['action'] = 'give_process_donation'; + $_POST['action'] = 'give_process_donation'; + $_POST['give-form-id'] = $form_id; $error_slug = ''; $error_message = ''; @@ -181,6 +191,53 @@ static function ( $slug, $message ) use ( &$error_slug, &$error_message ) { self::assertNotEmpty( $error_message ); } + /** + * Test verify() with an empty hCaptcha response does not consume FST. + * + * @return void + */ + public function test_verify_empty_hcaptcha_response_skips_fst(): void { + $form_id = 42; + + remove_filter( 'hcap_verify_fst_token', '__return_true' ); + add_filter( + 'hcap_verify_fst_token', + static function () { + return new WP_Error( 'fst-replayed-or-expired', 'Token replayed or expired.' ); + }, + 9 + ); + + $_POST['action'] = 'give_process_donation'; + $_POST['give-form-id'] = $form_id; + $_POST['h-captcha-response'] = ''; + $_POST['hcaptcha_give_wp_form_nonce'] = wp_create_nonce( 'hcaptcha_give_wp_form' ); + $_POST['hcap_hp_test'] = ''; + $_POST['hcap_hp_sig'] = wp_create_nonce( 'hcap_hp_test' ); + $_POST['hcap_fst_token'] = 'bad-token'; + $_SERVER['HTTP_CLIENT_IP'] = '7.7.7.7'; + + $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; + } + ); + + $subject = new Form(); + + $subject->verify( true ); + + self::assertSame( 'invalid_hcaptcha', $error_slug ); + self::assertStringContainsString( 'Please complete the hCaptcha.', $error_message ); + self::assertStringNotContainsString( 'Token replayed or expired.', $error_message ); + } /** * Test verify() with the wrong action — early return. * @@ -264,7 +321,9 @@ public function test_verify_block_verified(): void { $_GET['givewp-route'] = 'donate'; $_GET['givewp-route-signature-id'] = 'givewp-donate'; $_POST['h-captcha-response'] = 'some-response'; + $_POST['give-form-id'] = 42; + $this->prepare_widget_id( 42 ); $this->prepare_verify_post( 'hcaptcha_give_wp_form_nonce', 'hcaptcha_give_wp_form' ); $subject = new Form(); @@ -275,6 +334,27 @@ public function test_verify_block_verified(): void { self::assertTrue( true ); } + /** + * Test verify_block() with the correct route and upgraded form id field — verified. + * + * @return void + */ + public function test_verify_block_verified_with_form_id(): void { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_GET['givewp-route'] = 'donate'; + $_GET['givewp-route-signature-id'] = 'givewp-donate'; + $_POST['h-captcha-response'] = 'some-response'; + $_POST['formId'] = 42; + + $this->prepare_widget_id( 42 ); + $this->prepare_verify_post( 'hcaptcha_give_wp_form_nonce', 'hcaptcha_give_wp_form' ); + + $subject = new Form(); + + $subject->verify_block(); + + self::assertTrue( true ); + } /** * Test verify_block() with no h-captcha-response (empty string fallback). * @@ -296,6 +376,9 @@ public function test_verify_block_no_hcaptcha_response(): void { $_GET['givewp-route-signature-id'] = 'givewp-donate'; $this->prepare_verify_post( 'hcaptcha_give_wp_form_nonce', 'hcaptcha_give_wp_form', false ); + $this->prepare_widget_id( 42 ); + + $_POST['give-form-id'] = 42; unset( $_POST['h-captcha-response'] ); @@ -341,7 +424,9 @@ public function test_verify_block_not_verified(): void { $_GET['givewp-route'] = 'donate'; $_GET['givewp-route-signature-id'] = 'givewp-donate'; $_POST['h-captcha-response'] = 'bad-response'; + $_POST['give-form-id'] = 42; + $this->prepare_widget_id( 42 ); $this->prepare_verify_post( 'hcaptcha_give_wp_form_nonce', 'hcaptcha_give_wp_form', false ); add_filter( 'wp_doing_ajax', '__return_true' ); @@ -413,4 +498,20 @@ public function test_add_type_module(): void { self::assertStringContainsString( 'type="module"', $result ); } + + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * + * @return void + */ + private function prepare_widget_id( int $form_id ): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'give/give.php' ], + 'form_id' => $form_id, + ] + ); + } } diff --git a/tests/php/integration/GravityForms/FormTest.php b/tests/php/integration/GravityForms/FormTest.php index 4a99cbc0d..2b9b829fe 100644 --- a/tests/php/integration/GravityForms/FormTest.php +++ b/tests/php/integration/GravityForms/FormTest.php @@ -15,6 +15,7 @@ use GF_Field; use HCaptcha\GravityForms\Base; use HCaptcha\GravityForms\Form; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use Mockery; use ReflectionException; @@ -37,7 +38,8 @@ public function tearDown(): void { $_POST['input_3_3'], $_POST['input_3_6'], $_POST['input_4'], - $_POST['gform_submit'] + $_POST['gform_submit'], + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] ); parent::tearDown(); @@ -293,6 +295,7 @@ public function test_verify( string $mode ): void { update_option( 'hcaptcha_settings', [ 'gravity_status' => [ $mode ] ] ); $this->prepare_verify_post( Base::NONCE, Base::ACTION ); + $this->prepare_widget_id( $form_id ); hcaptcha()->init_hooks(); @@ -366,6 +369,7 @@ public function test_verify_not_verified( string $mode ): void { FunctionMocker::replace( 'GFFormsModel::get_form_meta', $form ); $this->prepare_verify_post( Base::NONCE, Base::ACTION, false ); + $this->prepare_widget_id( $form_id ); update_option( 'hcaptcha_settings', [ 'gravity_status' => [ $mode ] ] ); hcaptcha()->init_hooks(); @@ -375,6 +379,79 @@ public function test_verify_not_verified( string $mode ): void { self::assertSame( $expected, $subject->verify( $validation_result, $context ) ); } + /** + * Test verify() when widget id is missing. + * + * @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, + ] + ), + ], + ]; + $validation_result = [ + 'is_valid' => true, + 'form' => $form, + 'failed_validation_page' => 0, + ]; + $expected = [ + 'is_valid' => false, + 'form' => array_merge( $form, [ 'validationSummary' => '1' ] ), + 'failed_validation_page' => 0, + ]; + $context = 'form-submit'; + + $_POST['input_3_3'] = 'John'; + $_POST['input_3_6'] = 'Doe'; + $_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' ] ] ); + hcaptcha()->init_hooks(); + + $subject = new Form(); + + self::assertSame( $expected, $subject->verify( $validation_result, $context ) ); + self::assertSame( + [ + [ + 'field_selector' => '', + 'field_label' => 'hCaptcha', + 'message' => 'Bad hCaptcha signature!', + ], + ], + $subject->form_validation_errors( [], $form ) + ); + } + /** * Data provider for test_verify(). * @@ -625,6 +702,27 @@ public function test_enqueue_scripts(): void { self::assertTrue( wp_script_is( Form::HANDLE ) ); } + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * @param array $id Widget id. + * + * @return void + * @noinspection PhpSameParameterValueInspection + */ + private function prepare_widget_id( int $form_id, array $id = [] ): void { + $id = array_merge( + [ + 'source' => [ 'gravityforms/gravityforms.php' ], + 'form_id' => $form_id, + ], + $id + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + /** * Get GF_Field object. * diff --git a/tests/php/integration/HCaptchaWPTestCase.php b/tests/php/integration/HCaptchaWPTestCase.php index d4bff60ae..578726fd4 100644 --- a/tests/php/integration/HCaptchaWPTestCase.php +++ b/tests/php/integration/HCaptchaWPTestCase.php @@ -125,7 +125,7 @@ protected function set_protected_property( object $subject, string $property_nam } /** - * Set accessibility of protected method. + * Set the accessibility of a protected method. * * @param object $subject Object. * @param string $method_name Property name. @@ -211,7 +211,7 @@ protected function get_hcap_form( array $args = [] ): string { return $this->get_hcap_widget( $args['id'] ) . ' General::MODE_TEST_PUBLISHER_SITE_KEY, - 'action' => '', - 'name' => '', - 'auto' => false, - 'ajax' => false, - 'force' => false, - 'theme' => '', - 'size' => '', - 'id' => [], - 'protect' => true, + 'sitekey' => General::MODE_TEST_PUBLISHER_SITE_KEY, + 'action' => '', + 'name' => '', + 'auto' => false, + 'hcaptcha_class' => 'h-captcha', + 'ajax' => false, + 'force' => false, + 'theme' => '', + 'size' => '', + 'id' => [], + 'protect' => true, ], $args ); @@ -258,7 +259,7 @@ private function prepare_hcap_form_args( array $args ): array { } /** - * Prepare response from \HCaptcha\Helpers\API::verify_request(). + * Prepare a response from \HCaptcha\Helpers\API::verify_request(). * * @param string $hcaptcha_response hCaptcha response. * @param bool|null $result Desired result. diff --git a/tests/php/integration/Helpers/APITest.php b/tests/php/integration/Helpers/APITest.php index 931cb4f0a..7505bdbc8 100644 --- a/tests/php/integration/Helpers/APITest.php +++ b/tests/php/integration/Helpers/APITest.php @@ -8,8 +8,11 @@ namespace HCaptcha\Tests\Integration\Helpers; use HCaptcha\Helpers\API; +use HCaptcha\Helpers\FormSubmitTime; use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use ReflectionException; +use WP_Error; /** * Test API class. @@ -80,6 +83,29 @@ public function test_verify_post_default_empty(): void { self::assertSame( 'Please complete the hCaptcha.', API::verify_post() ); } + /** + * Test verify_post() checks an empty hCaptcha response before FST. + */ + public function test_verify_post_empty_checks_hcaptcha_before_fst(): void { + $settings = (array) get_option( 'hcaptcha_settings', [] ); + $settings['set_min_submit_time'] = 'on'; + $settings['min_submit_time'] = '0'; + + update_option( 'hcaptcha_settings', $settings ); + + $this->prepare_verify_request( '', false ); + + remove_filter( 'hcap_verify_fst_token', '__return_true' ); + add_filter( + 'hcap_verify_fst_token', + static function () { + return new WP_Error( 'fst-replayed-or-expired', 'Token replayed or expired.' ); + }, + 9 + ); + + self::assertSame( 'Please complete the hCaptcha.', API::verify_post() ); + } /** * Test verify_post(). */ @@ -280,7 +306,7 @@ public function test_verify_request_when_protection_not_enabled(): void { } /** - * Test verify_request() with empty string as argument. + * Test verify_request() with an empty string as an argument. */ public function test_verify_request_empty(): void { $this->prepare_verify_request( '', false ); @@ -312,4 +338,170 @@ public function test_verify_request_not_verified_empty_body(): void { self::assertSame( 'The hCaptcha is invalid.', API::verify_request( $hcaptcha_response ) ); } + /** + * Test verify_post_data(). + */ + public function test_verify_post_data(): void { + $hcaptcha_response = 'some response'; + + $this->prepare_verify_request( $hcaptcha_response ); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $post_data = $_POST; + $_POST = []; + + self::assertNull( API::verify_post_data( HCAPTCHA_NONCE, HCAPTCHA_ACTION, $post_data ) ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing + self::assertArrayNotHasKey( 'h-captcha-response', $_POST ); + } + + /** + * Test verify() without nonce and expected widget id. + */ + public function test_verify_without_nonce_and_expected_widget_id(): void { + $hcaptcha_response = 'some response'; + + $this->prepare_verify_request( $hcaptcha_response ); + + self::assertNull( + API::verify( + [ + 'nonce_name' => null, + 'nonce_action' => null, + 'h-captcha-response' => $hcaptcha_response, + ] + ) + ); + } + + /** + * Test verify_request() for a denylisted IP. + */ + public function test_verify_request_denylisted_ip(): void { + add_filter( 'hcap_blacklist_ip', '__return_true' ); + + self::assertSame( 'The hCaptcha is invalid.', API::verify_request( 'some response' ) ); + } + + /** + * Test verify_request() with a failed honeypot. + */ + public function test_verify_request_honeypot_failure(): void { + $hcaptcha_response = 'some response'; + + $this->prepare_verify_request( $hcaptcha_response ); + + $_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. + * + * @throws ReflectionException ReflectionException. + */ + public function test_verify_request_without_fst_object(): void { + $hcaptcha_response = 'some response'; + $settings = (array) get_option( 'hcaptcha_settings', [] ); + + $settings['set_min_submit_time'] = [ 'on' ]; + + update_option( 'hcaptcha_settings', $settings ); + $this->prepare_verify_request( $hcaptcha_response ); + + remove_filter( 'hcap_verify_fst_token', '__return_true' ); + + $loaded_classes = $this->get_protected_property( hcaptcha(), 'loaded_classes' ); + $no_fst_classes = $loaded_classes; + + unset( $no_fst_classes[ FormSubmitTime::class ] ); + + $this->set_protected_property( hcaptcha(), 'loaded_classes', $no_fst_classes ); + + try { + self::assertSame( 'FST object does not exist.', API::verify_request( $hcaptcha_response ) ); + } finally { + $this->set_protected_property( hcaptcha(), 'loaded_classes', $loaded_classes ); + } + } + + /** + * Test verify_request() with a failed Form Submit Time token. + */ + public function test_verify_request_fst_token_failure(): void { + $hcaptcha_response = 'some response'; + $settings = (array) get_option( 'hcaptcha_settings', [] ); + + $settings['set_min_submit_time'] = [ 'on' ]; + + update_option( 'hcaptcha_settings', $settings ); + $this->prepare_verify_request( $hcaptcha_response ); + + remove_filter( 'hcap_verify_fst_token', '__return_true' ); + add_filter( + 'hcap_verify_fst_token', + static function () { + return new WP_Error( 'fst-replayed-or-expired', 'Token replayed or expired.' ); + } + ); + + self::assertSame( 'Token replayed or expired.', API::verify_request( $hcaptcha_response ) ); + } + + /** + * Test verify_request() with a disposable email. + */ + public function test_verify_request_disposable_email_failure(): void { + $hcaptcha_response = 'some response'; + $settings = (array) get_option( 'hcaptcha_settings', [] ); + + $settings['disposable_email'] = [ 'on' ]; + + update_option( 'hcaptcha_settings', $settings ); + $this->prepare_verify_request( $hcaptcha_response ); + + add_filter( 'hcap_is_disposable_email', '__return_true' ); + + self::assertSame( + 'Please use a permanent email address.', + API::verify_request( + $hcaptcha_response, + [ + 'data' => [ 'email' => 'test@example.com' ], + ] + ) + ); + } + + /** + * Test verify_request() with a remote request error. + */ + public function test_verify_request_remote_error(): void { + $hcaptcha_response = 'some response'; + $hcaptcha_settings = (array) get_option( 'hcaptcha_settings', [] ); + $filter = static function () { + return new WP_Error( 'remote-error', 'Remote failed.' ); + }; + + $_POST['h-captcha-response'] = $hcaptcha_response; + $_POST['hcap_hp_test'] = ''; + $_POST['hcap_hp_sig'] = wp_create_nonce( 'hcap_hp_test' ); + + $hcaptcha_settings['secret_key'] = 'test secret'; + $hcaptcha_settings['trusted_address_headers'] = [ 'HTTP_CLIENT_IP' ]; + + update_option( 'hcaptcha_settings', $hcaptcha_settings ); + hcaptcha()->init_hooks(); + + $_SERVER['HTTP_CLIENT_IP'] = '7.7.7.7'; + + add_filter( 'pre_http_request', $filter ); + + try { + self::assertSame( 'Remote failed.', API::verify_request( $hcaptcha_response ) ); + } finally { + remove_filter( 'pre_http_request', $filter ); + } + } } diff --git a/tests/php/integration/Helpers/CloudflareDetectorTest.php b/tests/php/integration/Helpers/CloudflareDetectorTest.php new file mode 100644 index 000000000..a2d455127 --- /dev/null +++ b/tests/php/integration/Helpers/CloudflareDetectorTest.php @@ -0,0 +1,566 @@ +delete_detector_transients(); + + parent::tearDown(); + } + + /** + * Test get_statuses() and get_recommendation(). + * + * @return void + */ + public function test_get_statuses_and_recommendations(): void { + $statuses = CloudflareDetector::get_statuses(); + + self::assertSame( + [ + CloudflareDetector::STATUS_VERIFIED_REQUEST, + CloudflareDetector::STATUS_HOSTNAME_LIKELY_PROXIED, + CloudflareDetector::STATUS_HEADERS_DETECTED_UNVERIFIED, + CloudflareDetector::STATUS_NOT_DETECTED, + ], + $statuses + ); + + foreach ( $statuses as $status ) { + self::assertNotSame( '', CloudflareDetector::get_recommendation( $status ) ); + } + + self::assertSame( '', CloudflareDetector::get_recommendation( 'unknown' ) ); + } + + /** + * Test get_context() for a verified Cloudflare request. + * + * @return void + */ + public function test_get_context_verified_request(): void { + set_transient( 'hcaptcha_cloudflare_ip_ranges_v1', [ '173.245.48.0/20' ] ); + + $_SERVER['REMOTE_ADDR'] = '173.245.48.1'; + $_SERVER['HTTP_CF_CONNECTING_IP'] = '198.51.100.7'; + + $context = CloudflareDetector::get_context(); + + self::assertSame( CloudflareDetector::STATUS_VERIFIED_REQUEST, $context['status'] ); + self::assertSame( 'high', $context['confidence'] ); + self::assertSame( '173.245.48.1', $context['remote_addr'] ); + self::assertSame( '198.51.100.7', $context['visitor_ip'] ); + self::assertTrue( $context['cf_headers'] ); + self::assertSame( CloudflareDetector::STATUS_VERIFIED_REQUEST, CloudflareDetector::get_status() ); + } + + /** + * Test get_context() for a successful /cdn-cgi/trace probe. + * + * @return void + */ + public function test_get_context_trace_probe(): void { + $filter = static function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => "h=test.test\nip=203.0.113.10\ncolo=RIX\nts=123\n", + ]; + }; + + set_transient( 'hcaptcha_cloudflare_ip_ranges_v1', [ '173.245.48.0/20' ] ); + + $_SERVER['REMOTE_ADDR'] = '198.51.100.10'; + + add_filter( 'pre_http_request', $filter ); + + try { + $context = CloudflareDetector::get_context(); + } finally { + remove_filter( 'pre_http_request', $filter ); + } + + self::assertSame( CloudflareDetector::STATUS_HOSTNAME_LIKELY_PROXIED, $context['status'] ); + self::assertSame( 'medium', $context['confidence'] ); + self::assertTrue( $context['trace_probe']['success'] ); + self::assertFalse( $context['cf_headers'] ); + } + + /** + * Test get_context() for a successful DNS probe. + * + * @return void + */ + public function test_get_context_dns_probe(): void { + set_transient( 'hcaptcha_cloudflare_ip_ranges_v1', [ '203.0.113.0/24' ] ); + set_transient( + $this->trace_cache_key(), + [ + 'success' => false, + 'url' => home_url( '/cdn-cgi/trace' ), + 'data' => [], + 'error' => '', + ] + ); + set_transient( + $this->dns_cache_key(), + [ + 'success' => true, + 'host' => 'test.test', + 'ips' => [ '203.0.113.10' ], + ] + ); + + $_SERVER['REMOTE_ADDR'] = '198.51.100.10'; + + $context = CloudflareDetector::get_context(); + + self::assertSame( CloudflareDetector::STATUS_HOSTNAME_LIKELY_PROXIED, $context['status'] ); + self::assertSame( 'medium', $context['confidence'] ); + self::assertTrue( $context['dns_probe']['success'] ); + } + + /** + * Test get_context() for Cloudflare headers without verification. + * + * @return void + */ + public function test_get_context_headers_detected_unverified(): void { + $this->prepare_negative_context_probes(); + + $_SERVER['REMOTE_ADDR'] = '198.51.100.10'; + $_SERVER['HTTP_CF_RAY'] = 'ray-id'; + + $context = CloudflareDetector::get_context(); + + self::assertSame( CloudflareDetector::STATUS_HEADERS_DETECTED_UNVERIFIED, $context['status'] ); + self::assertSame( 'low', $context['confidence'] ); + self::assertTrue( $context['cf_headers'] ); + } + + /** + * Test get_context() when Cloudflare is not detected. + * + * @return void + */ + public function test_get_context_not_detected(): void { + $this->prepare_negative_context_probes(); + + $_SERVER['REMOTE_ADDR'] = '198.51.100.10'; + + $context = CloudflareDetector::get_context(); + + self::assertSame( CloudflareDetector::STATUS_NOT_DETECTED, $context['status'] ); + self::assertSame( 'none', $context['confidence'] ); + self::assertFalse( $context['cf_headers'] ); + } + + /** + * Test get_cloudflare_ranges(). + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_cloudflare_ranges(): void { + $subject = new CloudflareDetector(); + $method = $this->set_method_accessibility( $subject, 'get_cloudflare_ranges' ); + + set_transient( 'hcaptcha_cloudflare_ip_ranges_v1', [ '203.0.113.0/24' ] ); + + self::assertSame( [ '203.0.113.0/24' ], $method->invoke( null ) ); + + delete_transient( 'hcaptcha_cloudflare_ip_ranges_v1' ); + + $success_filter = static function ( $preempt, $parsed_args, $url ) { + if ( 'https://www.cloudflare.com/ips-v4' === $url ) { + return [ + 'response' => [ 'code' => 200 ], + 'body' => "203.0.113.0/24\ninvalid\n203.0.113.0/24\n", + ]; + } + + if ( 'https://www.cloudflare.com/ips-v6' === $url ) { + return [ + 'response' => [ 'code' => 200 ], + 'body' => "2001:db8::/32\n", + ]; + } + + return null; + }; + + add_filter( 'pre_http_request', $success_filter, 10, 3 ); + + try { + self::assertSame( [ '203.0.113.0/24', '2001:db8::/32' ], $method->invoke( null ) ); + } finally { + remove_filter( 'pre_http_request', $success_filter ); + } + + delete_transient( 'hcaptcha_cloudflare_ip_ranges_v1' ); + + $fallback_filter = static function ( $preempt, $parsed_args, $url ) { + if ( 'https://www.cloudflare.com/ips-v4' === $url ) { + return new WP_Error( 'download-error', 'Download failed.' ); + } + + return [ + 'response' => [ 'code' => 500 ], + 'body' => '', + ]; + }; + + add_filter( 'pre_http_request', $fallback_filter, 10, 3 ); + + try { + $ranges = $method->invoke( null ); + } finally { + remove_filter( 'pre_http_request', $fallback_filter ); + } + + self::assertContains( '173.245.48.0/20', $ranges ); + } + + /** + * Test get_cdn_cgi_trace_probe(). + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_cdn_cgi_trace_probe(): void { + $subject = new CloudflareDetector(); + $method = $this->set_method_accessibility( $subject, 'get_cdn_cgi_trace_probe' ); + $cached = [ + 'success' => true, + 'url' => home_url( '/cdn-cgi/trace' ), + 'data' => [ 'h' => 'cached' ], + 'error' => '', + ]; + + set_transient( $this->trace_cache_key(), $cached ); + + self::assertSame( $cached, $method->invoke( null ) ); + + delete_transient( $this->trace_cache_key() ); + + $this->assert_trace_probe_response( + new WP_Error( 'trace-error', 'Trace failed.' ), + [ + 'success' => false, + 'error' => 'Trace failed.', + ] + ); + + $this->assert_trace_probe_response( + [ + 'response' => [ 'code' => 404 ], + 'body' => 'not-a-trace', + ], + [ + 'success' => false, + 'http_code' => 404, + ] + ); + + $this->assert_trace_probe_response( + [ + 'response' => [ 'code' => 200 ], + 'body' => "h=test.test\nip=203.0.113.10\ncolo=RIX\nts=123\n", + ], + [ + 'success' => true, + 'http_code' => 200, + ] + ); + } + + /** + * Test get_dns_probe(). + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_dns_probe(): void { + $subject = new CloudflareDetector(); + $method = $this->set_method_accessibility( $subject, 'get_dns_probe' ); + $cached = [ + 'success' => true, + 'host' => 'test.test', + 'ips' => [ '203.0.113.10' ], + ]; + + set_transient( $this->dns_cache_key(), $cached ); + + self::assertSame( $cached, $method->invoke( null ) ); + + delete_transient( $this->dns_cache_key() ); + set_transient( 'hcaptcha_cloudflare_ip_ranges_v1', [ '203.0.113.0/24' ] ); + + $this->mock_resolvers( + [ + [ 'ip' => '203.0.113.10' ], + ], + [] + ); + + $result = $method->invoke( null ); + + self::assertTrue( $result['success'] ); + self::assertSame( [ '203.0.113.10' ], $result['ips'] ); + + $home_url_filter = static function () { + return ''; + }; + + delete_transient( $this->dns_cache_key() ); + add_filter( 'home_url', $home_url_filter ); + + try { + self::assertSame( + [ + 'success' => false, + 'host' => '', + 'ips' => [], + 'error' => 'Unable to parse site host.', + ], + $method->invoke( null ) + ); + } finally { + remove_filter( 'home_url', $home_url_filter ); + } + } + + /** + * Test private helpers. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_private_helpers(): void { + $subject = new CloudflareDetector(); + + $parse_trace_body = $this->set_method_accessibility( $subject, 'parse_cdn_cgi_trace_body' ); + $looks_like_trace = $this->set_method_accessibility( $subject, 'looks_like_cloudflare_trace' ); + $is_valid_cidr = $this->set_method_accessibility( $subject, 'is_valid_cidr' ); + $ip_in_ranges = $this->set_method_accessibility( $subject, 'ip_in_cloudflare_ranges' ); + $fallback_ranges = $this->set_method_accessibility( $subject, 'get_fallback_cloudflare_ranges' ); + + $data = $parse_trace_body->invoke( + null, + "\nno-equals\nh=test.test\nip=203.0.113.10\ncolo=RIX\nts=123\nbad key=value\n!!!=skip\n" + ); + + self::assertSame( 'test.test', $data['h'] ); + self::assertSame( 'value', $data['badkey'] ); + self::assertArrayNotHasKey( '', $data ); + + self::assertFalse( $looks_like_trace->invoke( null, [] ) ); + self::assertFalse( + $looks_like_trace->invoke( + null, + [ + 'h' => 'test.test', + 'ip' => 'not-an-ip', + 'colo' => 'RIX', + 'ts' => '123', + ] + ) + ); + self::assertFalse( + $looks_like_trace->invoke( + null, + [ + 'h' => 'test.test', + 'ip' => '203.0.113.10', + 'colo' => 'RIGA', + 'ts' => '123', + ] + ) + ); + self::assertTrue( $looks_like_trace->invoke( null, $data ) ); + + self::assertFalse( $is_valid_cidr->invoke( null, '203.0.113.1' ) ); + self::assertFalse( $is_valid_cidr->invoke( null, 'not-an-ip/24' ) ); + self::assertFalse( $is_valid_cidr->invoke( null, '203.0.113.0/nope' ) ); + self::assertFalse( $is_valid_cidr->invoke( null, '203.0.113.0/33' ) ); + self::assertTrue( $is_valid_cidr->invoke( null, '203.0.113.0/24' ) ); + self::assertTrue( $is_valid_cidr->invoke( null, '2001:db8::/32' ) ); + + set_transient( 'hcaptcha_cloudflare_ip_ranges_v1', [ '203.0.113.0/24' ] ); + + self::assertFalse( $ip_in_ranges->invoke( null, 'not-an-ip' ) ); + self::assertFalse( $ip_in_ranges->invoke( null, '198.51.100.10' ) ); + self::assertTrue( $ip_in_ranges->invoke( null, '203.0.113.10' ) ); + + self::assertContains( '173.245.48.0/20', $fallback_ranges->invoke( null ) ); + } + + /** + * Test resolve_host_ips(). + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_resolve_host_ips(): void { + $subject = new CloudflareDetector(); + $method = $this->set_method_accessibility( $subject, 'resolve_host_ips' ); + + $this->mock_resolvers( + [ + [ 'ip' => '203.0.113.10' ], + [ 'ip' => 'not-an-ip' ], + [ 'ipv6' => '2001:db8::1' ], + ], + [ '203.0.113.10', '203.0.113.11', 'not-an-ip' ] + ); + + self::assertSame( + [ '203.0.113.10', '2001:db8::1', '203.0.113.11' ], + $method->invoke( null, 'example.com' ) + ); + + FunctionMocker::replace( 'function_exists', false ); + + self::assertSame( [], $method->invoke( null, 'example.com' ) ); + } + + /** + * Mock DNS resolvers. + * + * @param array $dns_records DNS records. + * @param array $host_records Host records. + * + * @return void + */ + private function mock_resolvers( array $dns_records, array $host_records ): void { + $dns_get_record = static function () use ( $dns_records ) { + return $dns_records; + }; + $gethostbynamel = static function () use ( $host_records ) { + return $host_records; + }; + + FunctionMocker::replace( + 'function_exists', + static function ( $function_name ) { + return in_array( $function_name, [ 'dns_get_record', 'gethostbynamel' ], true ); + } + ); + FunctionMocker::replace( 'HCaptcha\Helpers\dns_get_record', $dns_get_record ); + FunctionMocker::replace( 'HCaptcha\Helpers\gethostbynamel', $gethostbynamel ); + } + + /** + * Assert a trace probe response. + * + * @param array|WP_Error $response HTTP response. + * @param array $expected Expected partial result. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + private function assert_trace_probe_response( $response, array $expected ): void { + $subject = new CloudflareDetector(); + $method = $this->set_method_accessibility( $subject, 'get_cdn_cgi_trace_probe' ); + $filter = static function () use ( $response ) { + return $response; + }; + + delete_transient( $this->trace_cache_key() ); + add_filter( 'pre_http_request', $filter ); + + try { + $result = $method->invoke( null ); + } finally { + remove_filter( 'pre_http_request', $filter ); + } + + foreach ( $expected as $key => $value ) { + self::assertSame( $value, $result[ $key ] ); + } + } + + /** + * Prepare negative probes for context tests. + * + * @return void + */ + private function prepare_negative_context_probes(): void { + set_transient( 'hcaptcha_cloudflare_ip_ranges_v1', [ '203.0.113.0/24' ] ); + set_transient( + $this->trace_cache_key(), + [ + 'success' => false, + 'url' => home_url( '/cdn-cgi/trace' ), + 'data' => [], + 'error' => '', + ] + ); + set_transient( + $this->dns_cache_key(), + [ + 'success' => false, + 'host' => 'test.test', + 'ips' => [], + ] + ); + } + + /** + * Delete detector transients. + * + * @return void + */ + private function delete_detector_transients(): void { + delete_transient( 'hcaptcha_cloudflare_ip_ranges_v1' ); + delete_transient( $this->trace_cache_key() ); + delete_transient( $this->dns_cache_key() ); + } + + /** + * Get a trace cache key. + * + * @return string + */ + private function trace_cache_key(): string { + return 'hcaptcha_cf_trace_' . md5( home_url( '/cdn-cgi/trace' ) ); + } + + /** + * Get DNS cache key. + * + * @return string + */ + private function dns_cache_key(): string { + return 'hcaptcha_cf_dns_' . md5( 'test.test' ); + } +} diff --git a/tests/php/integration/Helpers/DBTest.php b/tests/php/integration/Helpers/DBTest.php index 88ab793bd..b60aa3c66 100644 --- a/tests/php/integration/Helpers/DBTest.php +++ b/tests/php/integration/Helpers/DBTest.php @@ -43,6 +43,7 @@ public function test_prepare_in( $items, ?string $format, string $expected ): vo */ public function dp_test_prepare_in(): array { return [ + 'Empty array' => [ [], null, '' ], 'Empty items' => [ '', null, "''" ], 'Single string item' => [ 'id', null, "'id'" ], 'Array string items' => [ [ 'id' ], null, "'id'" ], diff --git a/tests/php/integration/Helpers/HCaptchaTest.php b/tests/php/integration/Helpers/HCaptchaTest.php index 387a897ea..9c8f09bc4 100644 --- a/tests/php/integration/Helpers/HCaptchaTest.php +++ b/tests/php/integration/Helpers/HCaptchaTest.php @@ -16,7 +16,10 @@ use HCaptcha\CF7\CF7; use HCaptcha\Helpers\HCaptcha; use HCaptcha\Settings\Integrations; +use HCaptcha\Settings\PluginSettingsBase; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; +use ReflectionException; +use ReflectionMethod; use tad\FunctionMocker\FunctionMocker; use WP_Error; @@ -141,6 +144,35 @@ public function test_form_display(): void { self::assertFalse( strpos( ob_get_clean(), 'get_hcap_form( + [ + 'hcaptcha_class' => 'h-captcha hcaptcha-api-delayed', + ] + ), + ob_get_clean() + ); + } finally { + remove_filter( 'hcap_delay_api_event', $delay_api_event_filter ); + } + } + /** * Test HCaptcha::get_widget(). * @@ -593,4 +625,193 @@ public function test_add_type_module(): void { HCaptcha::add_type_module( '' ) ); } + /** + * Test form_display() signature hook. + * + * @return void + */ + public function test_form_display_signature_hook(): void { + $received_args = null; + $callback = static function ( array $args ) use ( &$received_args ) { + $received_args = $args; + }; + + add_action( 'hcap_signature_test', $callback ); + + try { + ob_start(); + HCaptcha::form_display( + [ + 'sign' => 'test', + 'protect' => false, + ] + ); + ob_get_clean(); + } finally { + remove_action( 'hcap_signature_test', $callback ); + } + + self::assertIsArray( $received_args ); + self::assertSame( 'test', $received_args['sign'] ); + } + + /** + * Test form_display() falls back to saved theme and size. + * + * @return void + */ + public function test_form_display_invalid_theme_and_size_fallback(): void { + update_option( + 'hcaptcha_settings', + [ + 'theme' => 'dark', + 'size' => 'compact', + ] + ); + hcaptcha()->init_hooks(); + + ob_start(); + HCaptcha::form_display( + [ + 'theme' => 'wrong-theme', + 'size' => 'wrong-size', + ] + ); + $form = ob_get_clean(); + + self::assertStringContainsString( 'data-theme="dark"', $form ); + self::assertStringContainsString( 'data-size="compact"', $form ); + } + + /** + * Test original protected get_hp_name(). + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_hp_name(): void { + $method = new ReflectionMethod( HCaptcha::class, 'get_hp_name' ); + + FunctionMocker::tearDown(); + $method->setAccessible( true ); + + try { + $name = $method->invoke( null ); + } finally { + $method->setAccessible( false ); + FunctionMocker::setUp(); + FunctionMocker::replace( '\HCaptcha\Helpers\HCaptcha::get_hp_name', 'hcap_hp_test' ); + } + + self::assertStringStartsWith( 'hcap_hp_', $name ); + } + + /** + * Test check_signature() with a mismatched source. + * + * @return void + */ + public function test_check_signature_source_mismatch(): void { + $class_name = CF7::class; + $form_id = 123; + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $name = HCaptcha::HCAPTCHA_SIGNATURE . '-' . base64_encode( $class_name ); + + $_POST[ $name ] = $this->get_encoded_signature( [ 'wrong/source.php' ], $form_id, true ); + + self::assertFalse( HCaptcha::check_signature( $class_name, $form_id ) ); + } + + /** + * Test get_source_name() when the integrations tab is unavailable. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_get_source_name_without_integrations(): void { + $main = hcaptcha(); + $original_modules = $main->modules; + $original_settings = $this->get_protected_property( $main, 'settings' ); + + $main->modules = [ + 'bbPress' => [ + [ 'bbp_status', null ], + 'bbpress/bbpress.php', + [], + ], + ]; + + $this->set_protected_property( $main, 'settings', null ); + + try { + self::assertSame( + 'bbpress/bbpress.php', + HCaptcha::get_source_name( wp_json_encode( [ 'bbpress/bbpress.php' ] ) ) + ); + } finally { + $this->set_protected_property( $main, 'settings', $original_settings ); + $main->modules = $original_modules; + } + } + + /** + * Test save_license_level(). + * + * @return void + */ + public function test_save_license_level(): void { + update_option( PluginSettingsBase::OPTION_NAME, [ 'license' => 'old' ] ); + + $this->save_license_level_with_response( new WP_Error( 'remote-error', 'Remote failed.' ) ); + + self::assertSame( 'old', get_option( PluginSettingsBase::OPTION_NAME )['license'] ); + + $this->save_license_level_with_response( + [ + 'body' => wp_json_encode( + [ + 'pass' => true, + 'features' => [ 'custom_theme' => false ], + ] + ), + ] + ); + + self::assertSame( 'free', get_option( PluginSettingsBase::OPTION_NAME )['license'] ); + + $this->save_license_level_with_response( + [ + 'body' => wp_json_encode( + [ + 'pass' => true, + 'features' => [ 'custom_theme' => true ], + ] + ), + ] + ); + + self::assertSame( 'pro', get_option( PluginSettingsBase::OPTION_NAME )['license'] ); + } + + /** + * Save license level with a mocked HTTP response. + * + * @param array|WP_Error $response HTTP response. + * + * @return void + */ + private function save_license_level_with_response( $response ): void { + $filter = static function () use ( $response ) { + return $response; + }; + + add_filter( 'pre_http_request', $filter ); + + try { + HCaptcha::save_license_level(); + } finally { + remove_filter( 'pre_http_request', $filter ); + } + } } diff --git a/tests/php/integration/Helpers/InstallTest.php b/tests/php/integration/Helpers/InstallTest.php new file mode 100644 index 000000000..2cd02e4e7 --- /dev/null +++ b/tests/php/integration/Helpers/InstallTest.php @@ -0,0 +1,73 @@ + wp_json_encode( + [ + 'pass' => true, + 'features' => [ 'custom_theme' => false ], + ] + ), + ]; + }; + $settings = $this->get_protected_property( hcaptcha(), 'settings' ); + + $this->set_protected_property( hcaptcha(), 'settings', null ); + add_filter( 'pre_http_request', $filter ); + + try { + $subject->activation_hook(); + } finally { + remove_filter( 'pre_http_request', $filter ); + + if ( null === hcaptcha()->settings() ) { + $this->set_protected_property( hcaptcha(), 'settings', $settings ); + } + } + + self::assertNotNull( hcaptcha()->settings() ); + self::assertSame( 'free', get_option( PluginSettingsBase::OPTION_NAME )['license'] ); + } +} diff --git a/tests/php/integration/Helpers/PlaygroundTest.php b/tests/php/integration/Helpers/PlaygroundTest.php new file mode 100644 index 000000000..9f2292245 --- /dev/null +++ b/tests/php/integration/Helpers/PlaygroundTest.php @@ -0,0 +1,578 @@ +get_protected_property( $subject, 'renew' ) ); + + $this->call_hook_closure( 'login_head' ); + + self::assertTrue( wp_style_is( 'admin-bar' ) ); + self::assertTrue( wp_script_is( 'admin-bar' ) ); + + FunctionMocker::replace( + 'function_exists', + static function ( $function_name ) { + return ! ( 'wp_admin_bar_render' === $function_name ); + } + ); + + ob_start(); + $this->call_hook_closure( 'login_footer' ); + $admin_bar = ob_get_clean(); + + self::assertStringContainsString( 'wpadminbar', $admin_bar ); + + $session = new class() { + /** + * Placeholder callback. + * + * @return void + * @noinspection PhpUnused + */ + public function maybe_update_nonce_user_logged_out(): void {} + }; + $wc = (object) [ 'session' => $session ]; + + add_filter( 'nonce_user_logged_out', [ $session, 'maybe_update_nonce_user_logged_out' ] ); + FunctionMocker::replace( + 'function_exists', + static function ( $function_name ) { + return 'WC' === $function_name; + } + ); + FunctionMocker::replace( + 'HCaptcha\Helpers\WC', + static function () use ( $wc ) { + return $wc; + } + ); + + $this->call_hook_closure( 'init', 20 ); + + self::assertFalse( has_filter( 'nonce_user_logged_out', [ $session, 'maybe_update_nonce_user_logged_out' ] ) ); + } + + /** + * Test constructor on a Playground host. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_constructor_playground_host(): void { + $filter = static function () { + return 'https://playground.wordpress.net/'; + }; + + add_filter( 'home_url', $filter ); + + try { + $subject = new Playground(); + } finally { + remove_filter( 'home_url', $filter ); + } + + self::assertSame( Playground::LOAD_PRIORITY, has_action( 'plugins_loaded', [ $subject, 'setup_playground' ] ) ); + self::assertFalse( $this->get_protected_property( $subject, 'renew' ) ); + } + + /** + * Test setup_playground(). + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_setup_playground(): void { + $subject = $this->make_subject(); + + $subject->setup_playground(); + + $data = get_transient( 'hcaptcha_playground_data' ); + $settings = get_option( 'hcaptcha_settings' ); + + self::assertTrue( $data['setup'] ); + self::assertSame( '/%postname%/', get_option( 'permalink_structure' ) ); + self::assertSame( 'pro', $settings['license'] ); + + $subject = $this->make_subject( true, [ 'setup' => true ] ); + + $subject->setup_playground(); + + self::assertTrue( get_transient( 'hcaptcha_playground_data' )['setup'] ); + } + + /** + * Test setup_plugin(). + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_setup_plugin(): void { + $subject = $this->make_subject(); + + wp_insert_post( + [ + 'post_type' => 'wpcf7_contact_form', + 'post_status' => 'publish', + 'post_title' => 'Contact Form', + ] + ); + + $methods = [ + 'setup_contact_form_7', + 'setup_elementor_pro', + 'setup_essential_addons', + 'setup_jetpack', + 'setup_mailchimp', + 'setup_spectra', + 'setup_ultimate_addons', + 'setup_woocommerce', + 'setup_wpforms', + ]; + + foreach ( $methods as $method_name ) { + $method = $this->set_method_accessibility( $subject, $method_name ); + $method->invoke( $subject ); + } + + $subject->setup_plugin( 'unknown/unknown.php', false ); + + $data = get_transient( 'hcaptcha_playground_data' ); + + self::assertTrue( $data['plugins']['unknown/unknown.php'] ); + self::assertNotNull( get_page_by_path( 'contact-form-7-test' ) ); + self::assertNotNull( get_page_by_path( 'wpforms-test' ) ); + + $subject = $this->make_subject( false, [ 'plugins' => [ 'jetpack/jetpack.php' => true ] ] ); + $subject->setup_plugin( 'jetpack/jetpack.php', false ); + + self::assertTrue( get_transient( 'hcaptcha_playground_data' )['plugins']['unknown/unknown.php'] ); + } + + /** + * Test setup_theme(). + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_setup_theme(): void { + $subject = $this->make_subject(); + $theme4 = $this->make_theme( '4.27.4' ); + $theme5 = $this->make_theme( '5.0.0-public-beta.2' ); + + $subject->setup_theme( 'Avada', $theme4, $theme4 ); + $subject->setup_theme( 'Divi', $theme4, $theme4 ); + $subject->setup_theme( 'Divi', $theme5, $theme4 ); + $subject->setup_theme( 'Extra', $theme4, $theme4 ); + $subject->setup_theme( 'Unknown', $theme4, $theme4 ); + + $data = get_transient( 'hcaptcha_playground_data' ); + + self::assertTrue( $data['themes']['Avada'] ); + self::assertTrue( $data['themes']['Divi'] ); + self::assertTrue( $data['themes']['Extra'] ); + self::assertArrayNotHasKey( 'Unknown', $data['themes'] ); + + $subject = $this->make_subject( false, [ 'themes' => [ 'Avada' => true ] ] ); + $subject->setup_theme( 'Avada', $theme4, $theme4 ); + + self::assertTrue( get_transient( 'hcaptcha_playground_data' )['themes']['Avada'] ); + } + + /** + * Test display and enqueue methods. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_display_and_enqueue_methods(): void { + $subject = $this->make_subject(); + + ob_start(); + $subject->head_styles(); + $styles = ob_get_clean(); + + self::assertStringContainsString( 'playground-icon.svg', $styles ); + + $subject->enqueue_scripts(); + + self::assertTrue( wp_script_is( 'hcaptcha-playground' ) ); + self::assertStringContainsString( + 'HCaptchaPlaygroundObject', + (string) wp_scripts()->get_data( 'hcaptcha-playground', 'data' ) + ); + } + + /** + * Test admin_bar_menu(). + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_admin_bar_menu(): void { + $subject = $this->make_subject(); + + require_once ABSPATH . WPINC . '/class-wp-admin-bar.php'; + + $bar = new WP_Admin_Bar(); + + do_action( 'login_init' ); + $bar->add_node( + [ + 'id' => 'search', + 'title' => 'Search', + ] + ); + + $subject->admin_bar_menu( $bar ); + + self::assertNull( $bar->get_node( 'search' ) ); + self::assertNotNull( $bar->get_node( 'hcaptcha-menu' ) ); + self::assertNotNull( $bar->get_node( 'hcaptcha-menu-wp-login' ) ); + } + + /** + * Test update_menu(). + * + * @return void + * @throws Exception Exception. + */ + public function test_update_menu(): void { + $subject = $this->make_subject(); + + $_REQUEST['nonce'] = 'bad nonce'; + $response = $this->call_ajax_method( [ $subject, 'update_menu' ] ); + + self::assertFalse( $response['success'] ); + + $_REQUEST['nonce'] = wp_create_nonce( 'hcaptcha-playground-update-menu' ); + $response = $this->call_ajax_method( [ $subject, 'update_menu' ] ); + + self::assertFalse( $response['success'] ); + + wp_set_current_user( $this->factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $_REQUEST['nonce'] = wp_create_nonce( 'hcaptcha-playground-update-menu' ); + $response = $this->call_ajax_method( [ $subject, 'update_menu' ] ); + + self::assertTrue( $response['success'] ); + self::assertContains( 'hcaptcha-menu-wp-login', wp_list_pluck( $response['data'], 'id' ) ); + } + + /** + * Test private helper methods. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_private_helpers(): void { + $subject = $this->make_subject(); + $get_href = $this->set_method_accessibility( $subject, 'get_href' ); + $is_playground_host = $this->set_method_accessibility( $subject, 'is_playground_host' ); + $is_wp_playground = $this->set_method_accessibility( $subject, 'is_wp_playground' ); + $get_divi_slug = $this->set_method_accessibility( $subject, 'get_divi_test_slug' ); + $insert_post = $this->set_method_accessibility( $subject, 'insert_post' ); + $setup_permalinks = $this->set_method_accessibility( $subject, 'setup_permalinks' ); + $setup_settings = $this->set_method_accessibility( $subject, 'setup_settings' ); + + $main = hcaptcha(); + $original_modules = $main->modules; + $main->modules = [ + 'WP Core' => [ [ 'wp_status', null ], '', [] ], + 'Plugin' => [ [ 'plugin_status', null ], 'missing/missing.php', [] ], + ]; + + try { + self::assertSame( 'https://example.test/', $get_href->invoke( $subject, 'wp_status', 'https://example.test/' ) ); + self::assertStringContainsString( + 'suggest_activate=plugin_status', + $get_href->invoke( $subject, 'plugin_status', 'https://example.test/plugin' ) + ); + } finally { + $main->modules = $original_modules; + } + + self::assertFalse( $is_playground_host->invoke( $subject ) ); + self::assertFalse( $is_wp_playground->invoke( $subject ) ); + + $filter = static function () { + return 'https://playground.wordpress.net/'; + }; + + add_filter( 'home_url', $filter ); + + try { + self::assertTrue( $is_playground_host->invoke( $subject ) ); + self::assertTrue( $is_wp_playground->invoke( $subject ) ); + } finally { + remove_filter( 'home_url', $filter ); + } + + self::assertSame( 'divi-test', $get_divi_slug->invoke( $subject ) ); + + wp_insert_post( + [ + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Divi 5 Test Page', + 'post_name' => 'divi5-test', + ] + ); + + self::assertSame( 'divi5-test', $get_divi_slug->invoke( $subject ) ); + + $original_stylesheet = get_stylesheet(); + + $this->make_theme( '5.0.0', 'Divi5' ); + $this->make_theme( '4.27.4', 'Divi4' ); + $this->make_theme( '1.0.0', 'not-divi', 'Not Divi' ); + + try { + switch_theme( 'Divi5' ); + self::assertSame( 'divi5-test', $get_divi_slug->invoke( $subject ) ); + + switch_theme( 'Divi4' ); + self::assertSame( 'divi-test', $get_divi_slug->invoke( $subject ) ); + + switch_theme( 'not-divi' ); + self::assertSame( 'divi5-test', $get_divi_slug->invoke( $subject ) ); + } finally { + switch_theme( $original_stylesheet ); + } + + $this->set_protected_property( $subject, 'renew', false ); + + $post_id = $insert_post->invoke( + $subject, + [ + 'title' => 'Existing Test Page', + 'name' => 'existing-test-page', + 'content' => 'first', + ] + ); + $same_id = $insert_post->invoke( + $subject, + [ + 'title' => 'Existing Test Page', + 'name' => 'existing-test-page', + 'content' => 'second', + ] + ); + + self::assertSame( $post_id, $same_id ); + + $this->set_protected_property( $subject, 'renew', true ); + + $new_id = $insert_post->invoke( + $subject, + [ + 'title' => 'Existing Test Page', + 'name' => 'existing-test-page', + 'content' => 'third', + ] + ); + + self::assertNotSame( $post_id, $new_id ); + + $setup_permalinks->invoke( $subject ); + $setup_settings->invoke( $subject ); + + self::assertSame( '/%postname%/', get_option( 'permalink_structure' ) ); + self::assertSame( 'pro', get_option( 'hcaptcha_settings' )['license'] ); + } + + /** + * Call the first closure registered for a hook. + * + * @param string $hook_name Hook name. + * @param int $priority Hook priority. + * + * @return void + */ + private function call_hook_closure( string $hook_name, int $priority = 10 ): void { + global $wp_filter; + + $callbacks = $wp_filter[ $hook_name ]->callbacks[ $priority ] ?? []; + + foreach ( $callbacks as $callback ) { + if ( ( $callback['function'] ?? null ) instanceof Closure ) { + $callback['function'](); + + return; + } + } + + self::fail( "No closure found for $hook_name." ); + } + /** + * Make a Playground subject. + * + * @param bool $renew Renew flag. + * @param array $data Playground data. + * + * @return Playground + * @throws ReflectionException Reflection exception. + */ + private function make_subject( bool $renew = true, array $data = [] ): Playground { + $reflection = new ReflectionClass( Playground::class ); + $subject = $reflection->newInstanceWithoutConstructor(); + + $this->set_protected_property( $subject, 'renew', $renew ); + $this->set_protected_property( $subject, 'data', $data ); + + return $subject; + } + + /** + * Make a WP_Theme instance. + * + * @param string $version Theme version. + * @param string $stylesheet Theme stylesheet. + * @param string $name Theme name. + * + * @return WP_Theme + * @noinspection PhpMethodParametersCountMismatchInspection + */ + private function make_theme( string $version, string $stylesheet = '', string $name = 'Divi' ): WP_Theme { + global $wp_filesystem; + + require_once ABSPATH . 'wp-admin/includes/file.php'; + + WP_Filesystem(); + + $stylesheet = $stylesheet ?: 'divi-' . sanitize_key( $version ); + $theme_root = trailingslashit( get_temp_dir() ) . 'hcaptcha-playground-themes'; + $theme_dir = trailingslashit( $theme_root ) . $stylesheet; + + wp_mkdir_p( $theme_dir ); + $wp_filesystem->put_contents( + trailingslashit( $theme_dir ) . 'style.css', + "/*\nTheme Name: $name\nVersion: $version\n*/\n", + FS_CHMOD_FILE + ); + + register_theme_directory( $theme_root ); + + global $wp_theme_directories; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporary test theme root must take precedence. + $wp_theme_directories = array_values( + array_unique( + array_merge( [ $theme_root ], (array) $wp_theme_directories ) + ) + ); + + search_theme_directories( true ); + wp_clean_themes_cache(); + + return new WP_Theme( $stylesheet, $theme_root ); + } + + /** + * Call an ajax method and decode the response. + * + * @param callable $callback Callback. + * + * @return array + * @throws Exception Exception. + * @noinspection ThrowRawExceptionInspection + * @noinspection JsonEncodingApiUsageInspection + */ + private function call_ajax_method( callable $callback ): array { + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () { + return static function () { + throw new Exception( 'wp_die' ); + }; + } + ); + + ob_start(); + + try { + $callback(); + } catch ( Exception $e ) { + if ( 'wp_die' !== $e->getMessage() ) { + throw $e; + } + } finally { + remove_all_filters( 'wp_die_ajax_handler' ); + remove_all_filters( 'wp_doing_ajax' ); + } + + $json = ob_get_clean(); + + return (array) json_decode( $json, true ); + } +} diff --git a/tests/php/integration/Jetpack/FormTest.php b/tests/php/integration/Jetpack/FormTest.php index e69646870..41020bdd3 100644 --- a/tests/php/integration/Jetpack/FormTest.php +++ b/tests/php/integration/Jetpack/FormTest.php @@ -40,9 +40,9 @@ public function test_add_captcha( string $content, string $expected ): void { public function dp_test_add_captcha(): array { $_SERVER['REQUEST_URI'] = 'http://test.test/'; - $hash = 'some hash'; - $hash_input = ""; - $args = [ + $hash = 'some hash'; + $hash_input = ""; + $args = [ 'action' => 'hcaptcha_jetpack', 'name' => 'hcaptcha_jetpack_nonce', 'id' => [ @@ -50,27 +50,62 @@ public function dp_test_add_captcha(): array { 'form_id' => 'contact_' . $hash, ], ]; - $hcaptcha = $this->get_hcap_form( $args ); + $hcaptcha = $this->get_hcap_form( $args ); + $compact_hcaptcha = $this->get_hcap_form( + array_merge( + $args, + [ + 'size' => 'compact', + ] + ) + ); + $wrong_hcaptcha = $this->get_hcap_form( + [ + 'action' => 'hcaptcha_action', + 'name' => 'hcaptcha_nonce', + 'id' => [ + 'source' => [], + 'form_id' => 0, + ], + 'size' => 'compact', + ] + ); return [ - 'Empty contact form' => [ '', '' ], - 'Classic contact form' => [ + 'Empty contact form' => [ '', '' ], + 'Classic contact form' => [ '
    Contact Us' . $hash_input . '
    ', '
    ' . $hcaptcha . '
    ' . $hash_input . '', ], - 'Classic contact form with hcaptcha' => [ - '[contact-form] Some contact form [hcaptcha][/contact-form]', - '[contact-form] Some contact form [hcaptcha][/contact-form]', + 'Classic contact form with hcaptcha shortcode' => [ + '
    [hcaptcha size="compact"]
    ' . $hash_input . '', + '
    ' . $compact_hcaptcha . '
    ' . $hash_input . '', ], - 'Block contact form' => [ + 'Classic contact form with hcaptcha markup' => [ + '
    ' . $wrong_hcaptcha . '
    ' . $hash_input . '', + '
    ' . $compact_hcaptcha . '
    ' . $hash_input . '', + ], + 'Block contact form' => [ '
    Contact Us' . $hash_input . '
    ', '
    ' . $hcaptcha . '
    Contact Us' . $hash_input . '', ], - 'Block contact form with hcaptcha' => [ - '
    Contact Us
    ', - '
    Contact Us
    ', + 'Block contact form with core button' => [ + '
    Contact Us' . $hash_input . '
    ', + '
    ' . $hcaptcha . '
    Contact Us' . $hash_input . '', + ], + 'Block contact form with submit button classes' => [ + '
    Contact Us' . $hash_input . '
    ', + '
    ' . $hcaptcha . '
    Contact Us' . $hash_input . '', + ], + 'Block contact form with hcaptcha shortcode' => [ + '
    [hcaptcha]
    Contact Us' . $hash_input . '', + '
    ' . $hcaptcha . '
    Contact Us' . $hash_input . '', + ], + 'Block contact form with hcaptcha markup' => [ + '
    Contact Us
    ' . $wrong_hcaptcha . '
    ' . $hash_input . '', + '
    Contact Us
    ' . $compact_hcaptcha . '
    ' . $hash_input . '
    ', ], - 'Block contact form and search form' => [ + 'Block contact form and search form' => [ '
    Contact Us' . $hash_input . '
    ' . '
    ', '
    ' . $hcaptcha . '
    Contact Us' . $hash_input . '' . diff --git a/tests/php/integration/Kadence/AdvancedFormTest.php b/tests/php/integration/Kadence/AdvancedFormTest.php index 00a966e71..4630c7b74 100644 --- a/tests/php/integration/Kadence/AdvancedFormTest.php +++ b/tests/php/integration/Kadence/AdvancedFormTest.php @@ -212,6 +212,7 @@ public function test_process_ajax(): void { $_POST['fieldtxa001'] = 'Hello there'; $this->prepare_verify_request( $hcaptcha_response ); + $this->prepare_widget_id( (int) $post_id ); $subject = Mockery::mock( AdvancedForm::class )->makePartial(); @@ -250,6 +251,7 @@ public function test_process_ajax_when_not_success( ?bool $result ): void { } $this->prepare_verify_request( $hcaptcha_response, $result ); + $this->prepare_widget_id( 0 ); if ( null === $result ) { unset( $_POST['h-captcha-response'] ); @@ -276,6 +278,38 @@ static function () use ( &$die_arr ) { $subject->process_ajax(); } + /** + * Test process_ajax() when widget id is missing. + * + * @return void + */ + public function test_process_ajax_missing_widget_id(): void { + $hcaptcha_response = 'some response'; + $error_message = 'Bad hCaptcha signature!'; + + $this->prepare_verify_request( $hcaptcha_response ); + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + $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(); + + $subject->process_ajax(); + } + /** * Data provider for test_process_ajax_when_not_success(). * @@ -288,6 +322,27 @@ public function dp_test_process_ajax_when_not_success(): array { ]; } + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * @param array $id Widget id. + * + * @return void + * @noinspection PhpSameParameterValueInspection + */ + private function prepare_widget_id( int $form_id, array $id = [] ): void { + $id = array_merge( + [ + 'source' => [ 'kadence-blocks/kadence-blocks.php' ], + 'form_id' => $form_id, + ], + $id + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + /** * Test enqueue_scripts(). * diff --git a/tests/php/integration/Kadence/FormTest.php b/tests/php/integration/Kadence/FormTest.php index a7aa79cf5..b86517ae5 100644 --- a/tests/php/integration/Kadence/FormTest.php +++ b/tests/php/integration/Kadence/FormTest.php @@ -12,6 +12,7 @@ namespace HCaptcha\Tests\Integration\Kadence; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Kadence\Form; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use Mockery; @@ -118,7 +119,7 @@ public function test_render_block(): void { self::assertSame( $expected, $subject->render_block( $block_content, $block, $instance ) ); - // No reCaptcha, add hCaptcha before submit. + // No reCaptcha, add hCaptcha before submitting. $block_content = '
    Some block content
    '; $expected = $hcap_form . $block_content; @@ -136,6 +137,7 @@ public function test_process_ajax(): void { $hcaptcha_response = 'some response'; $this->prepare_verify_request( $hcaptcha_response ); + $this->prepare_widget_id( 0 ); $subject = new Form(); @@ -149,6 +151,7 @@ public function test_process_ajax(): void { * * @return void * @dataProvider dp_test_process_ajax_when_not_success + * @noinspection JsonEncodingApiUsageInspection */ public function test_process_ajax_when_not_success( ?bool $result ): void { $hcaptcha_response = 'some response'; @@ -175,6 +178,7 @@ public function test_process_ajax_when_not_success( ?bool $result ): void { ]; $this->prepare_verify_request( $hcaptcha_response, $result ); + $this->prepare_widget_id( 0 ); if ( null === $result ) { unset( $_POST['h-captcha-response'] ); @@ -202,6 +206,55 @@ static function () use ( &$die_arr ) { self::assertSame( $expected, $die_arr ); } + /** + * Test process_ajax() when widget id is missing. + * + * @return void + * @noinspection JsonEncodingApiUsageInspection + */ + public function test_process_ajax_missing_widget_id(): void { + $hcaptcha_response = 'some response'; + $error_message = 'Bad hCaptcha signature!'; + $die_arr = []; + $expected_json = [ + 'success' => false, + 'data' => [ + 'html' => "
    $error_message
    ", + 'console' => 'hCaptcha Failed', + 'required' => null, + 'headers_sent' => true, + ], + ]; + $expected = [ + '', + '', + [ 'response' => null ], + ]; + + $this->prepare_verify_request( $hcaptcha_response ); + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + static function () use ( &$die_arr ) { + return static function ( $message, $title, $args ) use ( &$die_arr ) { + $die_arr = [ $message, $title, $args ]; + }; + } + ); + + $subject = new Form(); + + ob_start(); + $subject->process_ajax(); + $json = ob_get_clean(); + + $expected_json['data']['headers_sent'] = json_decode( $json, true )['data']['headers_sent']; + + self::assertSame( wp_json_encode( $expected_json ), $json ); + self::assertSame( $expected, $die_arr ); + } + /** * Data provider for test_process_ajax_when_not_success(). * @@ -214,6 +267,27 @@ public function dp_test_process_ajax_when_not_success(): array { ]; } + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * @param array $id Widget id. + * + * @return void + * @noinspection PhpSameParameterValueInspection + */ + private function prepare_widget_id( int $form_id, array $id = [] ): void { + $id = array_merge( + [ + 'source' => [ 'kadence-blocks/kadence-blocks.php' ], + 'form_id' => $form_id, + ], + $id + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + /** * Test enqueue_scripts(). * diff --git a/tests/php/integration/MailPoet/FormTest.php b/tests/php/integration/MailPoet/FormTest.php index f7da246eb..3aad8203c 100644 --- a/tests/php/integration/MailPoet/FormTest.php +++ b/tests/php/integration/MailPoet/FormTest.php @@ -12,6 +12,7 @@ namespace HCaptcha\Tests\Integration\MailPoet; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\MailPoet\Form; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use HCaptcha\Tests\Integration\Stubs\MailPoet\API\JSON\ResponseStub; @@ -55,7 +56,7 @@ public function tearDown(): void { $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'] ); + unset( $_POST['action'], $_POST['endpoint'], $_POST['method'], $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] ); parent::tearDown(); } @@ -142,6 +143,7 @@ public function test_verify(): void { ]; $this->prepare_verify_post( 'hcaptcha_mailpoet_nonce', 'hcaptcha_mailpoet' ); + $this->prepare_widget_id( 1 ); $subject->verify( $api ); @@ -149,10 +151,9 @@ public function test_verify(): void { unset( $_POST['data'] ); $this->prepare_verify_post( 'hcaptcha_mailpoet_nonce', 'hcaptcha_mailpoet' ); + $this->prepare_widget_id( 0 ); - // Suppress warning for an undefined array key "form_id" when data is empty. - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - @$subject->verify( $api ); + $subject->verify( $api ); } /** @@ -175,6 +176,7 @@ public function test_verify_with_data(): void { ]; $this->prepare_verify_post( 'hcaptcha_mailpoet_nonce', 'hcaptcha_mailpoet' ); + $this->prepare_widget_id( 1 ); $subject->verify( $api ); } @@ -186,7 +188,9 @@ public function test_verify_not_verified(): void { $code = 'fail'; $error_message = 'The hCaptcha is invalid.'; - Mockery::namedMock( Response::class, ResponseStub::class ); + if ( ! class_exists( Response::class, false ) ) { + Mockery::namedMock( Response::class, ResponseStub::class ); + } $error_response = Mockery::mock( ErrorResponse::class ); $api = Mockery::mock( API::class ); @@ -209,10 +213,66 @@ public function test_verify_not_verified(): void { ]; $this->prepare_verify_post( 'hcaptcha_mailpoet_nonce', 'hcaptcha_mailpoet', false ); + $this->prepare_widget_id( 1 ); + + $subject->verify( $api ); + } + + /** + * Test verify() when widget id is missing. + */ + 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 ) + ->andReturn( $error_response ); + + $subject = new Form(); + + $_POST['action'] = 'mailpoet'; + $_POST['endpoint'] = 'subscribers'; + $_POST['method'] = 'subscribe'; + $_POST['data'] = [ + 'form_id' => '1', + 'email' => '', + 'form_field_YjVlNWFkMmRiYTlhX2VtYWls' => 'foo@bar.com', + ]; + + $this->prepare_verify_post( 'hcaptcha_mailpoet_nonce', 'hcaptcha_mailpoet' ); $subject->verify( $api ); } + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * @param array $id Widget id. + * + * @return void + * @noinspection PhpSameParameterValueInspection + */ + private function prepare_widget_id( int $form_id, array $id = [] ): void { + $id = array_merge( + [ + 'source' => [ 'mailpoet/mailpoet.php' ], + 'form_id' => $form_id, + ], + $id + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + /** * Test enqueue_scripts(). */ diff --git a/tests/php/integration/Migrations/MigrationsTest.php b/tests/php/integration/Migrations/MigrationsTest.php index 5ef147bcd..117dba0dc 100644 --- a/tests/php/integration/Migrations/MigrationsTest.php +++ b/tests/php/integration/Migrations/MigrationsTest.php @@ -5,8 +5,12 @@ * @package HCaptcha\Tests */ +// phpcs:ignore Generic.Commenting.DocComment.MissingShort +/** @noinspection PhpUndefinedClassInspection */ + namespace HCaptcha\Tests\Integration\Migrations; +use ActionScheduler_Store; use HCaptcha\Admin\Events\Events; use HCaptcha\Helpers\HCaptcha; use HCaptcha\Migrations\Migrations; @@ -14,6 +18,7 @@ use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use Mockery; use ReflectionException; +use tad\FunctionMocker\FunctionMocker; /** * Test MigrationsTest class. @@ -34,6 +39,8 @@ public function setUp(): void { // Disable temporary tables creating. remove_all_filters( 'query', 10 ); + + $this->drop_events_table(); } /** @@ -41,6 +48,9 @@ public function setUp(): void { */ public function tearDown(): void { unset( $_GET['service-worker'], $GLOBALS['current_screen'] ); + delete_transient( 'hcaptcha_async_migrate_4_11_0' ); + delete_transient( 'hcaptcha_async_migrate_5_0_0' ); + delete_transient( 'hcaptcha_async_migrate_5_1_0' ); parent::tearDown(); } @@ -149,6 +159,7 @@ public function test_migrate(): void { // Do not run async migrations via Action Scheduler. set_transient( 'hcaptcha_async_migrate_4_11_0', Migrations::COMPLETED ); set_transient( 'hcaptcha_async_migrate_5_0_0', Migrations::COMPLETED ); + set_transient( 'hcaptcha_async_migrate_5_1_0', Migrations::COMPLETED ); $subject->migrate(); @@ -199,6 +210,370 @@ public function test_init_creates_events_table_when_no_migration_is_pending(): v self::assertTrue( Events::table_exists() ); } + /** + * Test load_action_scheduler(). + * + * @return void + */ + public function test_load_action_scheduler(): void { + $subject = new Migrations(); + + $subject->load_action_scheduler(); + + self::assertTrue( function_exists( 'as_get_scheduled_actions' ) ); + } + + /** + * Test migration methods without an upgrade version are skipped. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_migrations_without_upgrade_version_are_skipped(): void { + $subject = new class() extends Migrations { + + /** + * Migration without a version in the method name. + * + * @return bool|null + * @noinspection PhpUnused + */ + public function migrate_without_version(): ?bool { + return true; + } + }; + $method = $this->set_method_accessibility( $subject, 'maybe_prepare_migration_option' ); + + delete_option( Migrations::MIGRATED_VERSIONS_OPTION_NAME ); + + $method->invoke( $subject ); + + $migrated = get_option( Migrations::MIGRATED_VERSIONS_OPTION_NAME, [] ); + + self::assertIsArray( $migrated ); + self::assertArrayNotHasKey( '', $migrated ); + + $subject->migrate(); + + self::assertArrayNotHasKey( '', get_option( Migrations::MIGRATED_VERSIONS_OPTION_NAME, [] ) ); + } + + /** + * Test maybe_create_tables() returns after the table check was already done. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_maybe_create_tables_returns_after_first_check(): void { + set_current_screen( 'some-screen' ); + + $subject = new Migrations(); + $method = $this->set_method_accessibility( $subject, 'maybe_create_tables' ); + + $method->invoke( $subject ); + + self::assertTrue( $this->get_protected_property( $subject, 'tables_check_done' ) ); + } + + /** + * Test async_migrate_4_11_0(). + * + * @return void + */ + public function test_async_migrate_4_11_0(): void { + global $wpdb; + + $table_name = $wpdb->prefix . Events::TABLE_NAME; + $subject = new Migrations(); + + Events::create_table(); + + add_action( 'async_migrate_4_11_0', [ $subject, 'async_migrate_4_11_0' ] ); + + do_action( 'async_migrate_4_11_0' ); + + $indexes = $this->get_index_sub_parts( + $table_name, + [ + 'idx_date_source_form', + 'hcaptcha_id', + ] + ); + + self::assertSame( Migrations::COMPLETED, (int) get_transient( 'hcaptcha_async_migrate_4_11_0' ) ); + self::assertArrayHasKey( 'idx_date_source_form', $indexes ); + self::assertArrayNotHasKey( 'hcaptcha_id', $indexes ); + + Events::create_table( true ); + } + + /** + * Test async_migrate_5_0_0(). + * + * @return void + */ + public function test_async_migrate_5_0_0(): void { + global $wpdb; + + $table_name = $wpdb->prefix . Events::TABLE_NAME; + $subject = new Migrations(); + $filter = static function (): array { + return [ 'HTTP_CF_CONNECTING_IP' ]; + }; + + $this->create_legacy_events_table(); + add_filter( 'hcap_trusted_address_headers', $filter ); + add_action( 'async_migrate_5_0_0', [ $subject, 'async_migrate_5_0_0' ] ); + + try { + do_action( 'async_migrate_5_0_0' ); + } finally { + remove_filter( 'hcap_trusted_address_headers', $filter ); + } + + $option = get_option( PluginSettingsBase::OPTION_NAME, [] ); + + self::assertSame( [ 'HTTP_CF_CONNECTING_IP' ], $option['trusted_address_headers'] ); + self::assertSame( Migrations::COMPLETED, (int) get_transient( 'hcaptcha_async_migrate_5_0_0' ) ); + self::assertContains( 'status', $this->get_column_names( $table_name ) ); + self::assertContains( 'trashed_at_gmt', $this->get_column_names( $table_name ) ); + self::assertSame( + [ + 'status_date_gmt' => [ + 'status' => null, + 'date_gmt' => null, + ], + 'status_source_form' => [ + 'status' => null, + 'source' => 191, + 'form_id' => null, + ], + ], + $this->get_index_sub_parts( + $table_name, + [ + 'status_date_gmt', + 'status_source_form', + ] + ) + ); + } + + /** + * Test async_migrate_5_1_0(). + * + * @return void + */ + public function test_async_migrate_5_1_0(): void { + global $wpdb; + + $table_name = $wpdb->prefix . Events::TABLE_NAME; + $subject = new Migrations(); + + Events::create_table(); + add_action( 'async_migrate_5_1_0', [ $subject, 'async_migrate_5_1_0' ] ); + + do_action( 'async_migrate_5_1_0' ); + + self::assertSame( Migrations::COMPLETED, (int) get_transient( 'hcaptcha_async_migrate_5_1_0' ) ); + self::assertSame( + [ + 'source' => [ + 'source' => 191, + ], + 'hcaptcha_id' => [ + 'source' => 191, + 'form_id' => null, + ], + 'idx_date_source_form' => [ + 'date_gmt' => null, + 'source' => 191, + 'form_id' => null, + ], + 'status_source_form' => [ + 'status' => null, + 'source' => 191, + 'form_id' => null, + ], + ], + $this->get_index_sub_parts( + $table_name, + [ + 'source', + 'hcaptcha_id', + 'idx_date_source_form', + 'status_source_form', + ] + ) + ); + } + + /** + * Test async migration scheduling records failed Action Scheduler status. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_run_async_sets_failed_transient_when_action_creation_fails(): void { + $subject = new Migrations(); + $method = $this->set_method_accessibility( $subject, 'run_async' ); + $hook = 'async_migrate_custom_async'; + $transient = 'hcaptcha_' . $hook; + $enqueue_args = []; + $filter = static function ( $pre, string $hook, array $args, string $group, int $priority, bool $unique ) use ( &$enqueue_args ): int { + $enqueue_args = compact( 'hook', 'args', 'group', 'priority', 'unique' ); + + return 0; + }; + + $subject->load_action_scheduler(); + delete_transient( $transient ); + add_filter( 'pre_as_enqueue_async_action', $filter, 10, 6 ); + + try { + $result = $method->invoke( $subject, 'migrate_custom_async' ); + + self::assertNull( $result ); + self::assertSame( Migrations::STARTED, (int) get_transient( $transient ) ); + + $this->run_last_init_callback_at_priority_20(); + + self::assertSame( Migrations::FAILED, (int) get_transient( $transient ) ); + self::assertSame( + [ + 'hook' => $hook, + 'args' => [], + 'group' => 'hcaptcha', + 'priority' => 10, + 'unique' => true, + ], + $enqueue_args + ); + } finally { + remove_filter( 'pre_as_enqueue_async_action', $filter ); + delete_transient( $transient ); + } + } + + /** + * Test create_as_action() with an existing started action. + * + * @return void + * @throws ReflectionException Reflection exception. + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_create_as_action_returns_started_for_existing_action(): void { + $subject = new Migrations(); + $method = $this->set_method_accessibility( $subject, 'create_as_action' ); + $hook = 'hcaptcha_existing_action_test'; + $group = 'hcaptcha'; + + $subject->load_action_scheduler(); + + $action_id = as_enqueue_async_action( $hook, [], $group, true ); + + try { + self::assertGreaterThan( 0, $action_id ); + self::assertSame( Migrations::STARTED, $method->invoke( $subject, $hook, [], $group ) ); + } finally { + as_unschedule_all_actions( $hook, [], $group ); + } + } + + /** + * Test add_trusted_address_headers() returns on settings that do not need migration. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_add_trusted_address_headers_returns_when_settings_do_not_need_migration(): void { + $subject = new Migrations(); + $method = $this->set_method_accessibility( $subject, 'add_trusted_address_headers' ); + + update_option( PluginSettingsBase::OPTION_NAME, 'not-an-array' ); + + $method->invoke( $subject ); + + self::assertSame( 'not-an-array', get_option( PluginSettingsBase::OPTION_NAME ) ); + + update_option( + PluginSettingsBase::OPTION_NAME, + [ + 'trusted_address_headers' => [ 'HTTP_X_FORWARDED_FOR' ], + ] + ); + + $method->invoke( $subject ); + + self::assertSame( + [ + 'trusted_address_headers' => [ 'HTTP_X_FORWARDED_FOR' ], + ], + get_option( PluginSettingsBase::OPTION_NAME ) + ); + } + + /** + * Test add_events_trash_folder() returns when the Events table is not available. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_add_events_trash_folder_returns_when_table_is_missing(): void { + $subject = new Migrations(); + $method = $this->set_method_accessibility( $subject, 'add_events_trash_folder' ); + + FunctionMocker::replace( '\HCaptcha\Admin\Events\Events::create_table' ); + FunctionMocker::replace( '\HCaptcha\Admin\Events\Events::table_exists', false ); + + $method->invoke( $subject ); + + self::assertFalse( Events::table_exists() ); + } + + /** + * Test table helper early returns. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_table_helpers_return_when_schema_items_already_match(): void { + global $wpdb; + + $subject = new Migrations(); + $table_name = $wpdb->prefix . Events::TABLE_NAME; + $add_index = $this->set_method_accessibility( $subject, 'add_index' ); + $replace_index = $this->set_method_accessibility( $subject, 'replace_index' ); + $add_column = $this->set_method_accessibility( $subject, 'add_column' ); + + Events::create_table(); + + $add_index->invoke( $subject, $table_name, 'date_gmt', 'date_gmt' ); + $replace_index->invoke( $subject, $table_name, 'missing_migration_index', 'source(191)', false ); + $add_column->invoke( $subject, $table_name, 'status', "VARCHAR(20) NOT NULL DEFAULT 'active'" ); + + self::assertContains( 'status', $this->get_column_names( $table_name ) ); + self::assertArrayHasKey( 'date_gmt', $this->get_index_sub_parts( $table_name, [ 'date_gmt' ] ) ); + } + + /** + * Test update_events_source_indexes() returns when the Events table is unavailable. + * + * @return void + * @throws ReflectionException Reflection exception. + */ + public function test_update_events_source_indexes_returns_when_table_is_missing(): void { + $subject = new Migrations(); + $method = $this->set_method_accessibility( $subject, 'update_events_source_indexes' ); + + FunctionMocker::replace( '\HCaptcha\Admin\Events\Events::create_table' ); + FunctionMocker::replace( '\HCaptcha\Admin\Events\Events::table_exists', false ); + + $method->invoke( $subject ); + + self::assertFalse( Events::table_exists() ); + } + /** * Compare migrated option data. * @@ -282,14 +657,14 @@ public function test_migrate_4_0_0(): void { status VARCHAR(20) NOT NULL DEFAULT 'active', trashed_at_gmt DATETIME NULL, PRIMARY KEY (id), - KEY source (source), + KEY source (source(191)), KEY form_id (form_id), - KEY hcaptcha_id (source, form_id), + KEY hcaptcha_id (source(191), form_id), KEY ip (ip), KEY uuid (uuid), KEY date_gmt (date_gmt), KEY status_date_gmt (status, date_gmt), - KEY status_source_form (status, source, form_id) + KEY status_source_form (status, source(191), form_id) ) $charset_collate;"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -311,6 +686,59 @@ static function ( $queries ) use ( &$actual_query ) { self::assertSame( array_filter( explode( ';', $expected_query ) ), $actual_query ); } + /** + * Test update_events_source_indexes(). + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_update_events_source_indexes(): void { + global $wpdb; + + $method_name = 'update_events_source_indexes'; + $subject = new Migrations(); + $table_name = $wpdb->prefix . Events::TABLE_NAME; + + $this->drop_events_table(); + + Events::create_table(); + + $method = $this->set_method_accessibility( $subject, $method_name ); + + $method->invoke( $subject ); + + self::assertSame( + [ + 'source' => [ + 'source' => 191, + ], + 'hcaptcha_id' => [ + 'source' => 191, + 'form_id' => null, + ], + 'idx_date_source_form' => [ + 'date_gmt' => null, + 'source' => 191, + 'form_id' => null, + ], + 'status_source_form' => [ + 'status' => null, + 'source' => 191, + 'form_id' => null, + ], + ], + $this->get_index_sub_parts( + $table_name, + [ + 'source', + 'hcaptcha_id', + 'idx_date_source_form', + 'status_source_form', + ] + ) + ); + } + /** * Drop the events table. * @@ -340,6 +768,135 @@ private function clear_events_table_created(): void { update_option( PluginSettingsBase::OPTION_NAME, $settings ); } + /** + * Create a legacy Events table without Trash Folder columns and indexes. + * + * @return void + */ + private function create_legacy_events_table(): void { + global $wpdb; + + $table_name = $wpdb->prefix . Events::TABLE_NAME; + $charset_collate = $wpdb->get_charset_collate(); + $source_index_length = Events::SOURCE_INDEX_LENGTH; + + $this->drop_events_table(); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter + $wpdb->query( + "CREATE TABLE $table_name ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + source VARCHAR(256) NOT NULL, + form_id VARCHAR(20) NOT NULL, + ip VARCHAR(39) NOT NULL, + user_agent VARCHAR(256) NOT NULL, + uuid VARCHAR(36) NOT NULL, + error_codes VARCHAR(256) NOT NULL, + date_gmt DATETIME NOT NULL, + PRIMARY KEY (id), + KEY source (source($source_index_length)), + KEY form_id (form_id), + KEY hcaptcha_id (source($source_index_length), form_id), + KEY date_gmt (date_gmt) + ) $charset_collate" + ); + // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange + } + + /** + * Get table column names. + * + * @param string $table_name Table name. + * + * @return string[] + */ + private function get_column_names( string $table_name ): array { + global $wpdb; + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter + $rows = (array) $wpdb->get_results( "SHOW COLUMNS FROM $table_name", ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + return array_map( + static function ( array $row ): string { + return $row['Field']; + }, + $rows + ); + } + + /** + * Run the last init callback registered at priority 20. + * + * @return void + */ + private function run_last_init_callback_at_priority_20(): void { + $callbacks = $GLOBALS['wp_filter']['init']->callbacks[20] ?? []; + + self::assertNotEmpty( $callbacks ); + + $callback = end( $callbacks ); + $function = $callback['function'] ?? null; + + self::assertIsCallable( $function ); + + $function(); + } + + /** + * Get index sub-parts. + * + * @param string $table_name Table name. + * @param string[] $index_names Index names. + * + * @return array + */ + private function get_index_sub_parts( string $table_name, array $index_names ): array { + global $wpdb; + + $index_map = array_fill_keys( $index_names, true ); + $sub_parts = array_fill_keys( $index_names, [] ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = (array) $wpdb->get_results( + $wpdb->prepare( + 'SELECT INDEX_NAME, COLUMN_NAME, SUB_PART + FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = %s + ORDER BY INDEX_NAME, SEQ_IN_INDEX', + $table_name + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + foreach ( $rows as $row ) { + $index_name = $row['INDEX_NAME']; + + if ( ! isset( $index_map[ $index_name ] ) ) { + continue; + } + + $sub_parts[ $index_name ][ $row['COLUMN_NAME'] ] = null === $row['SUB_PART'] ? + null : + (int) $row['SUB_PART']; + } + + return array_filter( + $sub_parts, + static function ( array $parts ): bool { + return [] !== $parts; + } + ); + } + /** * Test migrate_4_6_0(). * diff --git a/tests/php/integration/NF/FieldTest.php b/tests/php/integration/NF/FieldTest.php index 30b1ee9f1..145d20d30 100644 --- a/tests/php/integration/NF/FieldTest.php +++ b/tests/php/integration/NF/FieldTest.php @@ -10,6 +10,7 @@ namespace HCaptcha\Tests\Integration\NF; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\NF\Field; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use NF_Database_Migrations; @@ -32,7 +33,7 @@ class FieldTest extends HCaptchaPluginWPTestCase { * Tear down the test. */ public function tearDown(): void { - unset( $_POST['formData'] ); + unset( $_POST['formData'], $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] ); parent::tearDown(); } @@ -75,6 +76,7 @@ public function test_validate(): void { $data['fields'] = $fields; $this->prepare_verify_request( $field['value'] ); + $this->prepare_widget_id( $form_id ); $subject = new Field(); @@ -107,6 +109,7 @@ public function test_validate_without_field(): void { $data['fields'] = $fields; $this->prepare_verify_request( '', false ); + $this->prepare_widget_id( $form_id ); $subject = new Field(); @@ -139,12 +142,46 @@ public function test_validate_not_validated(): void { $data['fields'] = $fields; $this->prepare_verify_request( $field['value'], false ); + $this->prepare_widget_id( $form_id ); $subject = new Field(); self::assertSame( 'The hCaptcha is invalid.', $subject->validate( $field, $data ) ); } + /** + * Test validate() when widget id is missing. + * + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_validate_missing_widget_id(): void { + $form_id = $this->create_ninja_form(); + $form = Ninja_Forms()->form( $form_id ); + $fields = []; + + foreach ( $form->get_fields() as $field ) { + $fields[] = [ + 'id' => $field->get_id(), + 'value' => 'some value', + ]; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + $_POST['formData'] = json_encode( [ 'id' => (string) $form_id ] ); + + $field = [ + 'id' => 90, + 'value' => 'some value', + ]; + $data['fields'] = $fields; + + $this->prepare_verify_request( $field['value'] ); + + $subject = new Field(); + + self::assertSame( 'Bad hCaptcha signature!', $subject->validate( $field, $data ) ); + } + /** * Test hide_field_type(). * @@ -159,6 +196,26 @@ public function test_hide_field_type(): void { self::assertSame( $expected, $subject->hide_field_type( $hidden_field_types ) ); } + /** + * Prepare hCaptcha widget id. + * + * @param int $form_id Form id. + * @param array $id Widget id. + * + * @return void + */ + private function prepare_widget_id( int $form_id, array $id = [] ): void { + $id = array_merge( + [ + 'source' => [ 'ninja-forms/ninja-forms.php' ], + 'form_id' => $form_id, + ], + $id + ); + + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( $id ); + } + /** * Create a Ninja form. * diff --git a/tests/php/integration/Otter/FormTest.php b/tests/php/integration/Otter/FormTest.php index 5de1879cf..1cbbaa8b2 100644 --- a/tests/php/integration/Otter/FormTest.php +++ b/tests/php/integration/Otter/FormTest.php @@ -12,6 +12,7 @@ namespace HCaptcha\Tests\Integration\Otter; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Otter\Form; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use Mockery; @@ -141,19 +142,29 @@ public function test_verify( bool $verified ): void { $form_data_request->shouldReceive( 'has_error' )->andReturn( false ); - $action = 'hcaptcha_otter'; - $nonce = 'hcaptcha_otter_nonce'; + $action = 'hcaptcha_otter'; + $nonce = 'hcaptcha_otter_nonce'; + $form_id = 'fc0b7800'; + $widget_id = HCaptcha::widget_id_value( + [ + 'source' => [ 'otter-blocks/otter-blocks.php' ], + 'form_id' => $form_id, + ] + ); $this->prepare_verify_post( $nonce, $action, $verified ); $form_data_arr = [ 'form_data' => [ - 'h-captcha-response' => 'some response', - 'hcaptcha-widget-id' => 'some widget id', - 'hcaptcha_otter_nonce' => $_POST[ $nonce ], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash - 'hcap_fst_token' => 'some token', - 'hcap_hp_test' => '', - 'hcap_hp_sig' => 'some signature', + 'payload' => [ + 'formId' => 'wp-block-themeisle-blocks-form-' . $form_id, + ], + 'h-captcha-response' => 'some response', + 'hcaptcha-widget-id' => $widget_id, + $nonce => $_POST[ $nonce ], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + 'hcap_fst_token' => $_POST['hcap_fst_token'], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + 'hcap_hp_test' => $_POST['hcap_hp_test'], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + 'hcap_hp_sig' => $_POST['hcap_hp_sig'], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash ], ]; @@ -170,6 +181,40 @@ public function test_verify( bool $verified ): void { self::assertInstanceOf( Form_Data_Request::class, $result ); } + /** + * Test verify() when widget id is missing. + * + * @return void + */ + public function test_verify_missing_widget_id(): void { + $subject = new Form(); + $form_data_request = Mockery::mock( Form_Data_Request::class ); + $action = 'hcaptcha_otter'; + $nonce = 'hcaptcha_otter_nonce'; + + $this->prepare_verify_post( $nonce, $action ); + + $form_data_request->shouldReceive( 'has_error' )->andReturn( false ); + $form_data_request->shouldReceive( 'dump_data' )->with()->andReturn( + [ + 'form_data' => [ + 'payload' => [ + 'formId' => 'wp-block-themeisle-blocks-form-fc0b7800', + ], + 'h-captcha-response' => 'some response', + $nonce => $_POST[ $nonce ], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + 'hcap_fst_token' => $_POST['hcap_fst_token'], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + 'hcap_hp_test' => $_POST['hcap_hp_test'], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + 'hcap_hp_sig' => $_POST['hcap_hp_sig'], // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + ], + ] + ); + $form_data_request->shouldReceive( 'set_error' )->once()->with( 'bad-signature', 'Bad hCaptcha signature!' ); + + $result = $subject->verify( $form_data_request ); + + self::assertInstanceOf( Form_Data_Request::class, $result ); + } /** * Data provider for test_verify(). * @@ -225,6 +270,7 @@ public function test_get_entry_and_data(): void { ]; $post_data = [ 'payload' => [ + 'formId' => 'wp-block-themeisle-blocks-form-fc0b7800', 'formInputsData' => $inputs, 'postId' => $post_id, ], @@ -239,6 +285,13 @@ public function test_get_entry_and_data(): void { self::assertSame( 'token', $entry['h-captcha-response'] ); self::assertSame( $post->post_modified_gmt, $entry['form_date_gmt'] ); self::assertSame( $post_data, $entry['post_data'] ); + self::assertSame( + [ + 'source' => [ 'otter-blocks/otter-blocks.php' ], + 'form_id' => 'fc0b7800', + ], + $entry['expected_id'] + ); self::assertSame( 'jane@example.com', $entry['data']['email'] ); self::assertSame( 'Jane Doe', $entry['data']['name'] ); self::assertSame( 'Jane Doe', $entry['data']['Name'] ); diff --git a/tests/php/integration/PaidMembershipsPro/CheckoutTest.php b/tests/php/integration/PaidMembershipsPro/CheckoutTest.php index cfe0dfbf1..2ffb174e2 100644 --- a/tests/php/integration/PaidMembershipsPro/CheckoutTest.php +++ b/tests/php/integration/PaidMembershipsPro/CheckoutTest.php @@ -7,6 +7,7 @@ namespace HCaptcha\Tests\Integration\PaidMembershipsPro; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\PaidMembershipsPro\Checkout; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use tad\FunctionMocker\FunctionMocker; @@ -74,6 +75,7 @@ public function test_verify(): void { FunctionMocker::replace( 'pmpro_was_checkout_form_submitted', true ); $this->prepare_verify_post( 'hcaptcha_pmpro_checkout_nonce', 'hcaptcha_pmpro_checkout' ); + $this->prepare_widget_id(); $subject = new Checkout(); @@ -94,6 +96,7 @@ public function test_verify_not_verified(): void { FunctionMocker::replace( 'pmpro_was_checkout_form_submitted', true ); $this->prepare_verify_post( 'hcaptcha_pmpro_checkout_nonce', 'hcaptcha_pmpro_checkout', false ); + $this->prepare_widget_id(); $subject = new Checkout(); @@ -120,4 +123,17 @@ public function test_verify_not_submitted(): void { self::assertNull( $pmpro_msg ); self::assertNull( $pmpro_msgt ); } + /** + * Prepare hCaptcha widget id. + * + * @return void + */ + private function prepare_widget_id(): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'paid-memberships-pro/paid-memberships-pro.php' ], + 'form_id' => 'checkout', + ] + ); + } } diff --git a/tests/php/integration/PasswordProtected/ProtectTest.php b/tests/php/integration/PasswordProtected/ProtectTest.php index ba569e072..b6b5e0ddf 100644 --- a/tests/php/integration/PasswordProtected/ProtectTest.php +++ b/tests/php/integration/PasswordProtected/ProtectTest.php @@ -7,6 +7,7 @@ namespace HCaptcha\Tests\Integration\PasswordProtected; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\PasswordProtected\Protect; use HCaptcha\Tests\Integration\HCaptchaWPTestCase; use tad\FunctionMocker\FunctionMocker; @@ -74,6 +75,7 @@ public function test_verify( bool $verified ): void { $expected = $verified ? $errors : new WP_Error( 'fail', 'The hCaptcha is invalid.', 400 ); $this->prepare_verify_post( $nonce, $action, $verified ); + $this->prepare_widget_id(); $subject = new Protect(); @@ -81,6 +83,23 @@ public function test_verify( bool $verified ): void { self::assertEquals( $expected, $subject->verify( $errors ) ); } + /** + * Test verify() when widget id is missing. + * + * @return void + */ + public function test_verify_missing_widget_id(): void { + $action = 'hcaptcha_password_protected'; + $nonce = 'hcaptcha_password_protected_nonce'; + $errors = new WP_Error(); + $expected = new WP_Error( 'bad-signature', 'Bad hCaptcha signature!', 400 ); + + $this->prepare_verify_post( $nonce, $action ); + + $subject = new Protect(); + + self::assertEquals( $expected, $subject->verify( $errors ) ); + } /** * Data provider for test_verify(). * @@ -93,6 +112,20 @@ public function dp_test_verify(): array { ]; } + /** + * Prepare hCaptcha widget id. + * + * @return void + */ + private function prepare_widget_id(): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'password-protected/password-protected.php' ], + 'form_id' => 'protect', + ] + ); + } + /** * Test print_inline_styles(). * diff --git a/tests/php/integration/ProtectContent/ProtectContentTest.php b/tests/php/integration/ProtectContent/ProtectContentTest.php index 71053a1f6..1bbe86047 100644 --- a/tests/php/integration/ProtectContent/ProtectContentTest.php +++ b/tests/php/integration/ProtectContent/ProtectContentTest.php @@ -303,7 +303,7 @@ public function test_show_protection_page(): void { *{box-sizing:border-box;margin:0;padding:0}html{line-height:1.15;-webkit-text-size-adjust:100%;color:#5c6f8a;font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body{display:flex;flex-direction:column;height:100vh;min-height:100vh;margin-top:0;margin-bottom:0}.main-content{margin:8rem auto;max-width:60rem;padding-left:1.5rem}@media (width <=720px){.main-content{margin-top:4rem}}.h2{font-size:1.5rem;font-weight:500;line-height:2.25rem}@media (width <=720px){.h2{font-size:1.25rem;line-height:1.5rem}}body.theme-dark{background-color:#1b1b1d;color:#e3e3e3}body.theme-dark a{color:#00bcb7}body.theme-dark a:hover{color:#00bcb7;text-decoration:underline}body.theme-dark .footer-inner{border-top:1px solid #e3e3e3}body.theme-light{background-color:#fff;color:#5c6f8a}body.theme-light a{color:#0075ab}body.theme-light a:hover{color:#0075ab;text-decoration:underline}body.theme-light .footer-inner{border-top:1px solid #5c6f8a}a{background-color:#fff0;color:#0075ab;text-decoration:none;transition:color .15s ease}a:hover{color:#0075ab;text-decoration:underline}.main-content{margin:8rem auto;max-width:60rem;padding-left:1.5rem;padding-right:1.5rem;width:100%}.spacer{margin:2rem 0}.spacer-top{margin-top:2rem}.spacer-bottom{margin-bottom:2rem}@media (width <=720px){.main-content{margin-top:4rem}}.main-wrapper{align-items:center;display:flex;flex:1;flex-direction:column}.h1{font-size:2.5rem;font-weight:500;line-height:3.75rem}.h2{font-weight:500}.core-msg,.h2{font-size:1.5rem;line-height:2.25rem}.core-msg{font-weight:400}@media (width <=720px){.h1{font-size:1.5rem;line-height:1.75rem}.h2{font-size:1.25rem}.core-msg,.h2{line-height:1.5rem}.core-msg{font-size:1rem}}.text-center{text-align:center}.footer{font-size:.75rem;line-height:1.125rem;margin:0 auto;max-width:60rem;padding-left:1.5rem;padding-right:1.5rem;width:100%}.footer-inner{border-top:1px solid #5c6f8a;padding-bottom:1rem;padding-top:1rem}.clearfix:after{clear:both;content:"";display:table}.footer-text{margin-bottom:.5rem}.core-msg,.zone-name-title{overflow-wrap:break-word}@media (width <=720px){.zone-name-title{margin-bottom:1rem}}@media (prefers-color-scheme:dark){body{background-color:#1b1b1d;color:#e3e3e3}body a{color:#00bcb7}body a:hover{color:#00bcb7;text-decoration:underline}.footer-inner{border-top:1px solid #e3e3e3}}.main-content .h-captcha{margin-bottom:0}#hcaptcha-submit{display:none} diff --git a/tests/php/integration/UM/LoginTest.php b/tests/php/integration/UM/LoginTest.php index c9703ec3a..f33e7d953 100644 --- a/tests/php/integration/UM/LoginTest.php +++ b/tests/php/integration/UM/LoginTest.php @@ -7,6 +7,7 @@ namespace HCaptcha\Tests\Integration\UM; +use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use HCaptcha\UM\Login; use Mockery; @@ -320,7 +321,7 @@ public function dp_test_add_um_captcha(): array { } /** - * Test add_um_captcha() when login limit is not exceeded. + * Test add_um_captcha() when the login limit is not exceeded. * * @return void */ @@ -337,9 +338,10 @@ public function test_add_um_captcha_when_login_limit_is_not_exceeded(): void { } /** - * Test add_um_captcha() with wrong mode. + * Test add_um_captcha() with the wrong mode. * * @return void + * @noinspection PhpUndefinedFunctionInspection */ public function test_add_um_captcha_with_wrong_mode(): void { $fields = [ 'some fields' ]; @@ -416,11 +418,32 @@ public function test_verify(): void { $mode = $subject::UM_MODE; $this->prepare_verify_post( "hcaptcha_um_{$mode}_nonce", "hcaptcha_um_$mode" ); + $this->prepare_widget_id( $mode ); $subject->verify( $submitted_data ); self::assertFalse( UM()->form()->has_error( 'hcaptcha' ) ); } + /** + * Test verify() with submitted form id. + * + * @return void + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_verify_with_submitted_form_id(): void { + $submitted_data = []; + $subject = $this->get_subject(); + $mode = $subject::UM_MODE; + $form_id = 3140; + + $this->prepare_verify_post( "hcaptcha_um_{$mode}_nonce", "hcaptcha_um_$mode" ); + $_POST['form_id'] = (string) $form_id; + $this->prepare_widget_id( $form_id ); + + $subject->verify( $submitted_data ); + + self::assertFalse( UM()->form()->has_error( 'hcaptcha' ) ); + } /** * Test verify() not verified. * @@ -431,6 +454,7 @@ public function test_verify_not_verified(): void { $mode = $subject::UM_MODE; $this->prepare_verify_post( "hcaptcha_um_{$mode}_nonce", "hcaptcha_um_$mode", false ); + $this->prepare_widget_id( $mode ); $args['mode'] = $subject::UM_MODE; @@ -441,9 +465,29 @@ public function test_verify_not_verified(): void { } /** - * Test verify() when login limit is not exceeded. + * Test verify() when widget id is missing. + * + * @return void + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_verify_missing_widget_id(): void { + $subject = $this->get_subject(); + $mode = $subject::UM_MODE; + + $this->prepare_verify_post( "hcaptcha_um_{$mode}_nonce", "hcaptcha_um_$mode" ); + + $args['mode'] = $mode; + + $subject->verify( $args ); + + self::assertTrue( UM()->form()->has_error( 'hcaptcha' ) ); + self::assertSame( 'Bad hCaptcha signature!', UM()->form()->errors['hcaptcha'] ); + } + /** + * Test verify() when the login limit is not exceeded. * * @return void + * @noinspection PhpUndefinedFunctionInspection */ public function test_verify_when_login_limit_is_not_exceeded(): void { $submitted_data = [ 'some submitted data' ]; @@ -478,6 +522,21 @@ public function test_mute_login_hcaptcha_notice(): void { self::assertSame( '', $subject->mute_login_hcaptcha_notice( $message, $error_key ) ); } + /** + * Prepare hCaptcha widget id. + * + * @param int|string $mode UM mode or form id. + * + * @return void + */ + private function prepare_widget_id( $mode ): void { + $_POST[ HCaptcha::HCAPTCHA_WIDGET_ID ] = HCaptcha::widget_id_value( + [ + 'source' => [ 'ultimate-member/ultimate-member.php' ], + 'form_id' => $mode, + ] + ); + } /** * Get subject. * diff --git a/tests/php/integration/UninstallFileTest.php b/tests/php/integration/UninstallFileTest.php index 71a35d07a..19de797e8 100644 --- a/tests/php/integration/UninstallFileTest.php +++ b/tests/php/integration/UninstallFileTest.php @@ -100,9 +100,9 @@ static function ( $constant_name ) { error_codes VARCHAR(256) NOT NULL, date_gmt DATETIME NOT NULL, PRIMARY KEY (id), - KEY source (source), + KEY source (source(191)), KEY form_id (form_id), - KEY hcaptcha_id (source, form_id), + KEY hcaptcha_id (source(191), form_id), KEY ip (ip), KEY uuid (uuid), KEY date_gmt (date_gmt) diff --git a/tests/php/integration/WC/AddPaymentMethodTest.php b/tests/php/integration/WC/AddPaymentMethodTest.php index 209e40101..956a840cd 100644 --- a/tests/php/integration/WC/AddPaymentMethodTest.php +++ b/tests/php/integration/WC/AddPaymentMethodTest.php @@ -13,6 +13,7 @@ use HCaptcha\Helpers\HCaptcha; use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use HCaptcha\WC\AddPaymentMethod; +use tad\FunctionMocker\FunctionMocker; /** * Test AddPaymentMethod class. @@ -106,6 +107,34 @@ public function test_no_injection_for_other_template(): void { self::assertSame( $row, ob_get_clean() ); } + /** + * Test enqueue_styles(). + */ + public function test_enqueue_styles(): void { + $subject = new AddPaymentMethod(); + + FunctionMocker::replace( 'HCaptcha\WC\is_account_page', false ); + FunctionMocker::replace( 'HCaptcha\WC\is_wc_endpoint_url', true ); + + $subject->enqueue_styles(); + + self::assertFalse( wp_style_is( 'hcaptcha-wc-add-payment-method' ) ); + + FunctionMocker::replace( 'HCaptcha\WC\is_account_page', true ); + FunctionMocker::replace( 'HCaptcha\WC\is_wc_endpoint_url', false ); + + $subject->enqueue_styles(); + + self::assertFalse( wp_style_is( 'hcaptcha-wc-add-payment-method' ) ); + + FunctionMocker::replace( 'HCaptcha\WC\is_account_page', true ); + FunctionMocker::replace( 'HCaptcha\WC\is_wc_endpoint_url', true ); + + $subject->enqueue_styles(); + + self::assertTrue( wp_style_is( 'hcaptcha-wc-add-payment-method' ) ); + } + /** * Test verify(). */ @@ -122,6 +151,15 @@ public function test_verify(): void { self::assertSame( [], wc_get_notices() ); } + /** + * Test verify() with an already invalid form. + */ + public function test_verify_when_already_invalid(): void { + $subject = new AddPaymentMethod(); + + self::assertFalse( $subject->verify( false ) ); + } + /** * Test verify() not verified. */ diff --git a/tests/php/integration/WC/CheckoutTest.php b/tests/php/integration/WC/CheckoutTest.php index 38a814507..eaf5769c6 100644 --- a/tests/php/integration/WC/CheckoutTest.php +++ b/tests/php/integration/WC/CheckoutTest.php @@ -16,6 +16,7 @@ use HCaptcha\Tests\Integration\HCaptchaPluginWPTestCase; use HCaptcha\WC\Checkout; use Mockery; +use WC_Payment_Gateway; use WP_Error; use WP_REST_Request; @@ -132,6 +133,13 @@ public function test_add_block_captcha(): void { // Checkout block. self::assertSame( $expected, $subject->add_block_captcha( $block_content, $block, $instance ) ); + + $checkout_block = '
    '; + $block_content = $content1 . $checkout_block . $content2; + $expected = $content1 . $this->get_hcap_form( $args ) . $checkout_block . $content2; + + // Checkout block with the return-to-cart link enabled. + self::assertSame( $expected, $subject->add_block_captcha( $block_content, $block, $instance ) ); } /** @@ -152,6 +160,45 @@ public function test_verify(): void { self::assertSame( [], wc_get_notices() ); } + /** + * Test verify() skips Stripe frontend checkout request. + * + * @return void + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_verify_skips_stripe_frontend_checkout_request(): void { + $_GET['wc-ajax'] = 'wc_stripe_frontend_request'; + $_GET['path'] = '/wc-stripe/v1/checkout'; + + WC()->init(); + wc_clear_notices(); + + $subject = new Checkout(); + $subject->verify(); + + self::assertSame( [], wc_get_notices() ); + } + + /** + * Test verify() skips express payment methods. + * + * @return void + * @noinspection PhpUndefinedFunctionInspection + */ + public function test_verify_skips_express_payment_method(): void { + $_POST['payment_method'] = 'express_gateway'; + + $this->mock_express_payment_gateway(); + + WC()->init(); + wc_clear_notices(); + + $subject = new Checkout(); + $subject->verify(); + + self::assertSame( [], wc_get_notices() ); + } + /** * Test verify() not verified. * @@ -268,6 +315,26 @@ public function test_verify_block(): void { self::assertEquals( $expected, $subject->verify_block( $response, $handler, $request ) ); } + /** + * Test verify_block() skips express payment methods. + * + * @return void + */ + public function test_verify_block_skips_express_payment_method(): void { + $response = 'some response'; + $handler = []; + $request = new WP_REST_Request( '', '/wc/store/v1/checkout' ); + + $request->set_method( 'POST' ); + $request->set_body( wp_json_encode( [ 'payment_method' => 'express_gateway' ] ) ); + + $this->mock_express_payment_gateway(); + + $subject = new Checkout(); + + self::assertSame( $response, $subject->verify_block( $response, $handler, $request ) ); + } + /** * Test verify_block() error code mapping. * @@ -327,7 +394,7 @@ public function test_enqueue_scripts(): void { self::assertFalse( wp_script_is( 'hcaptcha-wc-block-checkout' ) ); $subject->add_block_captcha( - '', + '
    ', [ 'blockName' => 'woocommerce/checkout' ], Mockery::mock( 'WP_Block' ) ); @@ -347,6 +414,12 @@ public function test_enqueue_scripts_when_captcha_was_NOT_added(): void { self::assertFalse( wp_script_is( 'hcaptcha-wc-checkout' ) ); self::assertFalse( wp_script_is( 'hcaptcha-wc-block-checkout' ) ); + $subject->add_block_captcha( + '', + [ 'blockName' => 'woocommerce/checkout' ], + Mockery::mock( 'WP_Block' ) + ); + $subject->enqueue_scripts(); self::assertFalse( wp_script_is( 'hcaptcha-wc-checkout' ) ); @@ -374,6 +447,55 @@ public function test_add_type_module(): void { self::assertSame( $expected, $subject->add_type_module( $tag, 'hcaptcha-wc-block-checkout', '' ) ); } + /** + * Add a WooCommerce express payment gateway. + * + * @return void + */ + private function mock_express_payment_gateway(): void { + $gateway = new class() extends WC_Payment_Gateway { + + /** + * Constructor. + * + * @noinspection PhpDynamicFieldDeclarationInspection + */ + public function __construct() { + $this->id = 'express_gateway'; + $this->supports = [ 'wc_stripe_banner_checkout' ]; + } + + /** + * Whether the gateway is available. + * + * @return bool + * @noinspection PhpUnused + */ + public function is_available(): bool { + return true; + } + + /** + * Whether banner checkout is enabled. + * + * @return bool + * @noinspection PhpUnused + */ + public function banner_checkout_enabled(): bool { + return true; + } + }; + + add_filter( + 'woocommerce_available_payment_gateways', + static function ( array $gateways ) use ( $gateway ): array { + $gateways[ $gateway->id ] = $gateway; + + return $gateways; + } + ); + } + /** * Prepare widget id. */ diff --git a/tests/php/integration/WPDiscuz/CommentTest.php b/tests/php/integration/WPDiscuz/CommentTest.php index 2ad801720..1e688783d 100644 --- a/tests/php/integration/WPDiscuz/CommentTest.php +++ b/tests/php/integration/WPDiscuz/CommentTest.php @@ -14,6 +14,7 @@ use HCaptcha\WPDiscuz\Comment; use Mockery; use tad\FunctionMocker\FunctionMocker; +use function function_exists; /** * Test Comment class. @@ -49,7 +50,7 @@ public function setUp(): void { FunctionMocker::replace( 'function_exists', static function ( $function_name ) { - return 'wpDiscuz' === $function_name; + return 'wpDiscuz' === $function_name || function_exists( $function_name ); } ); FunctionMocker::replace( 'wpDiscuz', $this->wp_discuz ); @@ -62,6 +63,8 @@ static function ( $function_name ) { */ public function tearDown(): void { unset( $_POST['action'], $_POST['h-captcha-response'], $_POST['g-recaptcha-response'] ); + + parent::tearDown(); } /** @@ -88,7 +91,7 @@ public function test_init_hooks_without_wpdiscuz(): void { FunctionMocker::replace( 'function_exists', static function ( $function_name ) { - return 'wpDiscuz' !== $function_name; + return ! ( 'wpDiscuz' === $function_name ) && function_exists( $function_name ); } ); @@ -133,7 +136,7 @@ public function test_block_recaptcha_without_options(): void { FunctionMocker::replace( 'function_exists', static function ( $function_name ) { - return 'wpDiscuz' === $function_name; + return 'wpDiscuz' === $function_name || function_exists( $function_name ); } ); FunctionMocker::replace( 'wpDiscuz', $core ); diff --git a/tests/php/integration/WPDiscuz/SubscribeTest.php b/tests/php/integration/WPDiscuz/SubscribeTest.php index e1d09da44..599a40f44 100644 --- a/tests/php/integration/WPDiscuz/SubscribeTest.php +++ b/tests/php/integration/WPDiscuz/SubscribeTest.php @@ -14,6 +14,7 @@ use HCaptcha\WPDiscuz\Subscribe; use Mockery; use tad\FunctionMocker\FunctionMocker; +use function function_exists; /** * Test Subscribe class. @@ -50,7 +51,7 @@ public function setUp(): void { FunctionMocker::replace( 'function_exists', static function ( $function_name ) { - return 'wpDiscuz' === $function_name; + return 'wpDiscuz' === $function_name || function_exists( $function_name ); } ); FunctionMocker::replace( 'wpDiscuz', $this->wp_discuz ); diff --git a/tests/php/integration/_bootstrap.php b/tests/php/integration/_bootstrap.php index bfa76c900..ac2024fb7 100644 --- a/tests/php/integration/_bootstrap.php +++ b/tests/php/integration/_bootstrap.php @@ -9,30 +9,9 @@ * @author {AUTHOR} */ -use tad\FunctionMocker\FunctionMocker; +namespace HCaptcha\Tests\Integration; -$loader = require HCAPTCHA_PATH . '/vendor/autoload.php'; +$hcaptcha_path = dirname( __DIR__, 3 ); +$loader = require $hcaptcha_path . '/vendor/autoload.php'; $loader->addPsr4( '', __DIR__ . '/Stubs/', true ); - -FunctionMocker::init( - [ - 'blacklist' => [ - realpath( HCAPTCHA_PATH ), - ], - 'whitelist' => [ - realpath( HCAPTCHA_PATH . '/src/php' ), - realpath( HCAPTCHA_PATH . '/tests/php/integration/Stubs' ), - ], - 'redefinable-internals' => [ - 'class_exists', - 'constant', - 'defined', - 'filter_input', - 'function_exists', - 'setcookie', - 'time', - 'uniqid', - ], - ] -); diff --git a/tests/php/integration/function-mocker-bootstrap.php b/tests/php/integration/function-mocker-bootstrap.php new file mode 100644 index 000000000..4e8b7749e --- /dev/null +++ b/tests/php/integration/function-mocker-bootstrap.php @@ -0,0 +1,127 @@ + [ + hcaptcha_get_filesystem_root( $hcaptcha_path ), + ], + 'cache-path' => $cache_path, + 'whitelist' => hcaptcha_get_function_mocker_whitelist( $hcaptcha_path, $extra_whitelist ), + 'redefinable-internals' => hcaptcha_get_redefinable_internals(), + ] + ); +} + +/** + * Get third-party plugin paths that Patchwork can transform. + * + * @param string $wp_root_path WordPress root path. + * + * @return array + * @noinspection PhpUnused + */ +function hcaptcha_get_function_mocker_external_plugin_whitelist( string $wp_root_path ): array { + if ( '' === $wp_root_path ) { + return []; + } + + $paths = []; + + foreach ( [ 'contact-form-7', 'wpforo' ] as $plugin_slug ) { + $paths[] = $wp_root_path . '/wp-content/plugins/' . $plugin_slug; + } + + return $paths; +} + +/** + * Get the filesystem root for a path. + * + * @param string $path Path. + * + * @return string + */ +function hcaptcha_get_filesystem_root( string $path ): string { + $root = $path; + + while ( dirname( $root ) !== $root ) { + $root = dirname( $root ); + } + + return realpath( $root ) ?: $root; +} + +/** + * Get paths that Patchwork can transform. + * + * @param string $hcaptcha_path hCaptcha plugin path. + * @param array $extra_whitelist Extra paths that Patchwork can transform. + * + * @return array + */ +function hcaptcha_get_function_mocker_whitelist( string $hcaptcha_path, array $extra_whitelist ): array { + $paths = array_merge( + [ + $hcaptcha_path . '/src/php', + $hcaptcha_path . '/tests/php/integration/Stubs', + ], + $extra_whitelist + ); + + return array_values( + array_unique( + array_filter( + array_map( 'realpath', $paths ) + ) + ) + ); +} + +/** + * Get PHP internals that can be replaced by FunctionMocker. + * + * @return array + */ +function hcaptcha_get_redefinable_internals(): array { + return [ + 'class_exists', + 'constant', + 'date_create_immutable', + 'defined', + 'filter_input', + 'function_exists', + 'is_readable', + 'setcookie', + 'time', + 'uniqid', + ]; +} diff --git a/tests/php/unit/Admin/OnboardingWizardTest.php b/tests/php/unit/Admin/OnboardingWizardTest.php deleted file mode 100644 index 56f3320ff..000000000 --- a/tests/php/unit/Admin/OnboardingWizardTest.php +++ /dev/null @@ -1,145 +0,0 @@ -getProperty( $property_name ); - $property->setAccessible( true ); - $property->setValue( $subject, $value ); - $property->setAccessible( false ); - } - - /** - * Mock onboarding GET input. - * - * @param string $step Step parameter. - * @param string $nonce Nonce parameter. - * - * @return void - */ - private function mock_get_input( string $step, string $nonce ): void { - FunctionMocker::replace( - '\HCaptcha\Helpers\Request::filter_input', - static function ( $type, $name ) use ( $step, $nonce ) { - if ( INPUT_GET !== $type ) { - return ''; - } - - if ( OnboardingWizard::STEP_PARAM === $name ) { - return $step; - } - - if ( OnboardingWizard::NONCE_PARAM === $name ) { - return $nonce; - } - - return ''; - } - ); - } - - /** - * Test maybe_handle_direct_step(). - */ - public function test_maybe_handle_direct_step(): void { - $nonce = 'step_nonce'; - $url = 'https://test.test/wp-admin/admin.php?page=hcaptcha&tab=integrations'; - - $this->mock_get_input( '7', $nonce ); - - $tab = Mockery::mock( PluginSettingsBase::class )->makePartial(); - $general = Mockery::mock( General::class )->makePartial(); - $integrations = Mockery::mock( Integrations::class )->makePartial(); - - WP_Mock::userFunction( 'add_action' )->withAnyArgs(); - WP_Mock::userFunction( 'wp_doing_ajax' )->with()->once()->andReturn( false ); - WP_Mock::userFunction( 'current_user_can' )->with( 'manage_options' )->once()->andReturn( true ); - WP_Mock::userFunction( 'wp_verify_nonce' ) - ->with( $nonce, OnboardingWizard::STEP_ACTION ) - ->once() - ->andReturn( 1 ); - - $tab->shouldReceive( 'update_option' )->once()->with( OnboardingWizard::OPTION_NAME, 'step 7' ); - $tab->shouldReceive( 'tab_url' )->once()->with( $integrations )->andReturn( $url ); - - $subject = Mockery::mock( OnboardingWizard::class, [ $tab ] )->makePartial(); - $subject->shouldAllowMockingProtectedMethods(); - $subject->shouldReceive( 'redirect_after_direct_step' )->once()->with( $url ); - - $this->set_wizard_property( $subject, 'general_tab', $general ); - $this->set_wizard_property( $subject, 'integrations_tab', $integrations ); - - $subject->maybe_handle_direct_step(); - } - - /** - * Test maybe_handle_direct_step() with a bad nonce. - */ - public function test_maybe_handle_direct_step_with_bad_nonce(): void { - $nonce = 'bad_nonce'; - - $this->mock_get_input( '3', $nonce ); - - $tab = Mockery::mock( PluginSettingsBase::class )->makePartial(); - - WP_Mock::userFunction( 'add_action' )->withAnyArgs(); - WP_Mock::userFunction( 'wp_doing_ajax' )->with()->once()->andReturn( false ); - WP_Mock::userFunction( 'current_user_can' )->with( 'manage_options' )->once()->andReturn( true ); - WP_Mock::userFunction( 'wp_verify_nonce' ) - ->with( $nonce, OnboardingWizard::STEP_ACTION ) - ->once() - ->andReturn( false ); - - $tab->shouldReceive( 'update_option' )->never(); - $tab->shouldReceive( 'tab_url' )->never(); - - $subject = Mockery::mock( OnboardingWizard::class, [ $tab ] )->makePartial(); - $subject->shouldAllowMockingProtectedMethods(); - $subject->shouldReceive( 'redirect_after_direct_step' )->never(); - - $subject->maybe_handle_direct_step(); - } -} diff --git a/tests/php/unit/AntiSpam/AntiSpamTest.php b/tests/php/unit/AntiSpam/AntiSpamTest.php new file mode 100644 index 000000000..f4cf43471 --- /dev/null +++ b/tests/php/unit/AntiSpam/AntiSpamTest.php @@ -0,0 +1,292 @@ + [ 'email' => 'person@example.test' ], + 'email' => 'person@example.test', + ]; + + $subject = $this->make_subject( $entry ); + + self::assertSame( + [ + 'data' => [ 'email' => 'person@example.test' ], + 'name' => null, + 'email' => 'person@example.test', + 'form_date_gmt' => null, + ], + $this->get_protected_property( $subject, 'entry' ) + ); + } + + /** + * Test init() exits when anti-spam is off. + */ + public function test_init_when_antispam_is_off(): void { + $settings = Mockery::mock( Settings::class )->makePartial(); + $settings->shouldReceive( 'is_on' )->with( 'antispam' )->once()->andReturn( false ); + $settings->shouldReceive( 'get' )->never(); + + $this->mock_main_settings( $settings ); + + $subject = $this->make_subject(); + + $subject->init(); + } + + /** + * Test init() exits when the selected provider is unsupported or unconfigured. + */ + public function test_init_when_provider_is_not_configured(): void { + $settings = Mockery::mock( Settings::class )->makePartial(); + $settings->shouldReceive( 'is_on' )->with( 'antispam' )->once()->andReturn( true ); + $settings->shouldReceive( 'get' )->with( 'antispam_provider' )->once()->andReturn( 'native' ); + + $this->mock_main_settings( $settings ); + + $subject = $this->make_subject(); + + $subject->init(); + } + + /** + * Test init() with a configured provider. + * + * @throws ReflectionException Reflection exception. + */ + public function test_init_when_provider_is_configured(): void { + $this->alias_native_provider(); + + FunctionMocker::replace( '\HCaptcha\AntiSpam\AntiSpam::is_provider_configured', true ); + + $settings = Mockery::mock( Settings::class )->makePartial(); + $settings->shouldReceive( 'is_on' )->with( 'antispam' )->twice()->andReturn( true ); + $settings->shouldReceive( 'get' )->with( 'antispam_provider' )->twice()->andReturn( 'native' ); + + $this->mock_main_settings( $settings ); + + $subject = $this->make_subject(); + + WP_Mock::expectFilterAdded( + 'hcap_verify_request', + [ $subject, 'verify_request_filter' ], + AntiSpam::VERIFY_REQUEST_PRIORITY, + 3 + ); + + $subject->init(); + + self::assertInstanceOf( 'HCaptcha\AntiSpam\Native', $this->get_protected_property( $subject, 'provider' ) ); + self::assertSame( [], AntiSpam::get_protected_forms() ); + } + + /** + * Test init_hooks(). + * + * @throws ReflectionException Reflection exception. + */ + public function test_init_hooks(): void { + $subject = $this->make_subject(); + $method = $this->set_method_accessibility( $subject, 'init_hooks' ); + + WP_Mock::expectFilterAdded( + 'hcap_verify_request', + [ $subject, 'verify_request_filter' ], + AntiSpam::VERIFY_REQUEST_PRIORITY, + 3 + ); + + $method->invoke( $subject ); + } + + /** + * Test verify_request_filter(). + * + * @throws ReflectionException Reflection exception. + */ + public function test_verify_request_filter(): void { + $entry = [ + 'data' => [ 'email' => 'bot@example.test' ], + 'name' => null, + 'email' => null, + 'form_date_gmt' => null, + ]; + + $subject = $this->make_subject( $entry ); + $provider = Mockery::mock( ProviderBase::class ); + $provider->shouldReceive( 'verify' )->with( $entry )->once()->andReturn( 'Spam detected.' ); + + $this->set_protected_property( $subject, 'provider', $provider ); + + $error_info = (object) [ 'codes' => [] ]; + + self::assertSame( 'already failed', $subject->verify_request_filter( 'already failed', [], $error_info ) ); + self::assertSame( [], $error_info->codes ); + + self::assertSame( 'Spam detected.', $subject->verify_request_filter( null, [], $error_info ) ); + self::assertSame( [ 'spam' ], $error_info->codes ); + + $cached_error_info = (object) [ 'codes' => [] ]; + + self::assertSame( 'Spam detected.', $subject->verify_request_filter( null, [], $cached_error_info ) ); + self::assertSame( [], $cached_error_info->codes ); + } + + /** + * Test get_protected_forms() when anti-spam is off. + */ + public function test_get_protected_forms_when_antispam_is_off(): void { + $settings = Mockery::mock( Settings::class )->makePartial(); + $settings->shouldReceive( 'is_on' )->with( 'antispam' )->once()->andReturn( false ); + $settings->shouldReceive( 'get' )->never(); + + $this->mock_main_settings( $settings ); + + self::assertSame( [], AntiSpam::get_protected_forms() ); + } + + /** + * Test get_protected_forms() when the provider is unsupported or unconfigured. + */ + public function test_get_protected_forms_when_provider_is_not_configured(): void { + $settings = Mockery::mock( Settings::class )->makePartial(); + $settings->shouldReceive( 'is_on' )->with( 'antispam' )->once()->andReturn( true ); + $settings->shouldReceive( 'get' )->with( 'antispam_provider' )->once()->andReturn( 'native' ); + + $this->mock_main_settings( $settings ); + + self::assertSame( [], AntiSpam::get_protected_forms() ); + } + + /** + * Test provider list methods. + */ + public function test_provider_list_methods(): void { + self::assertSame( [], AntiSpam::get_supported_providers() ); + self::assertSame( [], AntiSpam::get_configured_providers() ); + } + + /** + * Test private provider helpers. + * + * @throws ReflectionException Reflection exception. + */ + public function test_private_provider_helpers(): void { + $subject = $this->make_subject(); + $get_provider_classname = $this->set_method_accessibility( $subject, 'get_provider_classname' ); + $is_provider_supported = $this->set_method_accessibility( $subject, 'is_provider_supported' ); + $is_provider_configured = $this->set_method_accessibility( $subject, 'is_provider_configured' ); + + self::assertSame( 'HCaptcha\\AntiSpam\\Native', $get_provider_classname->invoke( null, 'native' ) ); + self::assertFalse( $is_provider_supported->invoke( null, 'native' ) ); + self::assertFalse( $is_provider_configured->invoke( null, 'native' ) ); + } + + /** + * Alias test provider class to the provider name used by AntiSpam. + * + * @return void + */ + private function alias_native_provider(): void { + if ( class_exists( 'HCaptcha\AntiSpam\Native', false ) ) { + return; + } + + $provider = new class() extends ProviderBase { + /** + * Has the provider been configured with a valid API key? + * + * @return bool + */ + public static function is_configured(): bool { + return true; + } + + /** + * Verify entry. + * + * @param array $entry Entry data. + * + * @return string|null + */ + public function verify( array $entry ): ?string { + return null; + } + }; + + class_alias( get_class( $provider ), 'HCaptcha\AntiSpam\Native' ); + } + /** + * Make AntiSpam subject. + * + * @param array $entry Entry. + * + * @return AntiSpam + */ + private function make_subject( array $entry = [] ): AntiSpam { + $this->mock_wp_parse_args(); + + return new AntiSpam( $entry ); + } + + /** + * Mock wp_parse_args(). + * + * @return void + */ + private function mock_wp_parse_args(): void { + WP_Mock::userFunction( 'wp_parse_args' )->andReturnUsing( + static function ( array $args, array $defaults = [] ): array { + return array_merge( $defaults, $args ); + } + ); + } + + /** + * Mock hcaptcha()->settings(). + * + * @param Settings $settings Settings. + * + * @return void + */ + private function mock_main_settings( Settings $settings ): void { + $main = Mockery::mock( Main::class )->makePartial(); + $main->shouldReceive( 'settings' )->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->andReturn( $main ); + } +} diff --git a/tests/php/unit/AntiSpam/DisposableEmailTest.php b/tests/php/unit/AntiSpam/DisposableEmailTest.php index 3bb481b5e..a36401450 100644 --- a/tests/php/unit/AntiSpam/DisposableEmailTest.php +++ b/tests/php/unit/AntiSpam/DisposableEmailTest.php @@ -14,6 +14,9 @@ use HCaptcha\AntiSpam\DisposableEmail; use HCaptcha\Tests\Unit\HCaptchaTestCase; +use ReflectionException; +use stdClass; +use tad\FunctionMocker\FunctionMocker; use Mockery; use WP_Mock; use WP_Mock\Matcher\AnyInstance; @@ -26,10 +29,21 @@ */ class DisposableEmailTest extends HCaptchaTestCase { + /** + * Tear down the test. + * + * @return void + */ + public function tearDown(): void { + unset( $GLOBALS['wp_filesystem'] ); + + parent::tearDown(); + } + /** * Test constructor and init_hooks(). * - * @throws \ReflectionException ReflectionException. + * @throws ReflectionException ReflectionException. */ public function test_init_hooks(): void { WP_Mock::expectActionAdded( 'admin_notices', [ DisposableEmail::class, 'show_download_failed_notice' ] ); @@ -42,7 +56,7 @@ public function test_init_hooks(): void { } /** - * Test get_blocklist() returns cached transient. + * Test get_blocklist() returns a cached transient. */ public function test_get_blocklist_returns_cached(): void { $cached = [ @@ -66,7 +80,7 @@ public function test_get_blocklist_returns_cached(): void { } /** - * Test get_blocklist() reads file from uploads when no transient. + * Test get_blocklist() reads a file from uploads when no transient. */ public function test_get_blocklist_reads_file_from_uploads(): void { $file_content = "mailinator.com\nguerrillamail.com\n"; @@ -105,7 +119,7 @@ public function test_get_blocklist_reads_file_from_uploads(): void { } /** - * Test get_blocklist() returns empty array when file doesn't exist (graceful degradation). + * Test get_blocklist() returns an empty array when a file doesn't exist (graceful degradation). */ public function test_get_blocklist_returns_empty_when_file_missing(): void { $subject = Mockery::mock( DisposableEmail::class )->makePartial(); @@ -138,7 +152,7 @@ public function test_get_blocklist_returns_empty_when_file_missing(): void { } /** - * Test get_blocklist_path() returns uploads path. + * Test get_blocklist_path() returns uploads a path. */ public function test_get_blocklist_path(): void { WP_Mock::userFunction( 'wp_upload_dir' ) @@ -156,6 +170,190 @@ public function test_get_blocklist_path(): void { ); } + /** + * Test read_file() when WP_Filesystem init fails. + */ + public function test_read_file_when_filesystem_init_fails(): void { + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->mock_wp_filesystem_init( false ); + + self::assertFalse( $subject->read_file( '/tmp/blocklist.conf' ) ); + } + + /** + * Test read_file() when the file does not exist. + */ + public function test_read_file_when_file_does_not_exist(): void { + $file = '/tmp/blocklist.conf'; + $wp_filesystem = $this->mock_wp_filesystem(); + $wp_filesystem->shouldReceive( 'exists' )->with( $file )->once()->andReturn( false ); + $wp_filesystem->shouldReceive( 'get_contents' )->never(); + + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->mock_wp_filesystem_init( true ); + + self::assertFalse( $subject->read_file( $file ) ); + } + + /** + * Test read_file() returns contents. + */ + public function test_read_file_returns_contents(): void { + $file = '/tmp/blocklist.conf'; + $contents = "mailinator.com\n"; + $wp_filesystem = $this->mock_wp_filesystem(); + $wp_filesystem->shouldReceive( 'exists' )->with( $file )->once()->andReturn( true ); + $wp_filesystem->shouldReceive( 'get_contents' )->with( $file )->once()->andReturn( $contents ); + + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->mock_wp_filesystem_init( true ); + + self::assertSame( $contents, $subject->read_file( $file ) ); + } + + /** + * Test download_blocklist() when WP_Filesystem init fails. + */ + public function test_download_blocklist_when_filesystem_init_fails(): void { + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->mock_wp_filesystem_init( false ); + + self::assertFalse( $subject->download_blocklist() ); + } + + /** + * Test download_blocklist() when a remote request fails. + */ + public function test_download_blocklist_when_request_fails(): void { + $response = new stdClass(); + $this->mock_wp_filesystem(); + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->mock_wp_filesystem_init( true ); + + WP_Mock::userFunction( 'wp_remote_get' )->once()->andReturn( $response ); + WP_Mock::userFunction( 'is_wp_error' )->with( $response )->once()->andReturn( true ); + + self::assertFalse( $subject->download_blocklist() ); + } + + /** + * Test download_blocklist() when the response code is not OK. + */ + public function test_download_blocklist_when_response_code_is_not_ok(): void { + $response = new stdClass(); + $this->mock_wp_filesystem(); + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->mock_wp_filesystem_init( true ); + + WP_Mock::userFunction( 'wp_remote_get' )->once()->andReturn( $response ); + WP_Mock::userFunction( 'is_wp_error' )->with( $response )->once()->andReturn( false ); + WP_Mock::userFunction( 'wp_remote_retrieve_response_code' )->with( $response )->once()->andReturn( 500 ); + + self::assertFalse( $subject->download_blocklist() ); + } + + /** + * Test download_blocklist() when the response body is empty. + */ + public function test_download_blocklist_when_body_is_empty(): void { + $response = new stdClass(); + $this->mock_wp_filesystem(); + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->mock_wp_filesystem_init( true ); + + WP_Mock::userFunction( 'wp_remote_get' )->once()->andReturn( $response ); + WP_Mock::userFunction( 'is_wp_error' )->with( $response )->once()->andReturn( false ); + WP_Mock::userFunction( 'wp_remote_retrieve_response_code' )->with( $response )->once()->andReturn( 200 ); + WP_Mock::userFunction( 'wp_remote_retrieve_body' )->with( $response )->once()->andReturn( '' ); + + self::assertFalse( $subject->download_blocklist() ); + } + + /** + * Test download_blocklist() when directory creation fails. + */ + public function test_download_blocklist_when_directory_creation_fails(): void { + $base_dir = str_replace( '\\', '/', sys_get_temp_dir() ) . '/hcaptcha-antispam-uploads'; + $path = $this->mock_blocklist_path( $base_dir ); + $dir = dirname( $path ); + $wp_filesystem = $this->mock_wp_filesystem(); + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->mock_wp_filesystem_init( true ); + $this->mock_successful_remote_blocklist_response( "mailinator.com\n" ); + + $wp_filesystem->shouldReceive( 'is_dir' )->with( $dir )->once()->andReturn( false ); + $wp_filesystem->shouldReceive( 'put_contents' )->never(); + + WP_Mock::userFunction( 'wp_mkdir_p' )->with( $dir )->once()->andReturn( false ); + + self::assertFalse( $subject->download_blocklist() ); + } + + /** + * Test download_blocklist() success. + */ + public function test_download_blocklist_success(): void { + $base_dir = str_replace( '\\', '/', sys_get_temp_dir() ) . '/hcaptcha-antispam-uploads'; + $body = "mailinator.com\n"; + $path = $this->mock_blocklist_path( $base_dir ); + $dir = dirname( $path ); + $wp_filesystem = $this->mock_wp_filesystem(); + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->define_fs_chmod_file(); + $this->mock_wp_filesystem_init( true ); + $this->mock_successful_remote_blocklist_response( $body ); + + $wp_filesystem->shouldReceive( 'is_dir' )->with( $dir )->once()->andReturn( false ); + $wp_filesystem->shouldReceive( 'put_contents' ) + ->with( $path, $body, constant( 'HCaptcha\\AntiSpam\\FS_CHMOD_FILE' ) ) + ->once() + ->andReturn( true ); + + WP_Mock::userFunction( 'wp_mkdir_p' )->with( $dir )->once()->andReturn( true ); + + self::assertTrue( $subject->download_blocklist() ); + } + + /** + * Test schedule_update() when Action Scheduler is unavailable. + */ + public function test_schedule_update_without_action_scheduler(): void { + FunctionMocker::replace( 'function_exists', false ); + + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + $subject->schedule_update(); + } + + /** + * Test unschedule_update() when Action Scheduler is unavailable. + */ + public function test_unschedule_update_without_action_scheduler(): void { + FunctionMocker::replace( 'function_exists', false ); + + $subject = Mockery::mock( DisposableEmail::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + $subject->unschedule_update(); + } + /** * Test is_disposable_email(). * @@ -370,7 +568,7 @@ public function test_unschedule_update(): void { } /** - * Test activate() downloads blocklist and schedules update when file doesn't exist. + * Test activate() downloads blocklist and schedules update when the file doesn't exist. */ public function test_activate_downloads_and_schedules(): void { $subject = Mockery::mock( DisposableEmail::class )->makePartial(); @@ -472,4 +670,98 @@ public function test_show_download_failed_notice_no_transient(): void { self::assertEmpty( $output ); } + + /** + * Mock WP_Filesystem global. + * + * @return Mockery\LegacyMockInterface + */ + private function mock_wp_filesystem(): Mockery\LegacyMockInterface { + global $wp_filesystem; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_filesystem = Mockery::mock(); + + return $wp_filesystem; + } + + /** + * Mock WP_Filesystem init. + * + * @param bool $result WP_Filesystem result. + * + * @return void + */ + private function mock_wp_filesystem_init( bool $result ): void { + $this->define_namespaced_abspath(); + + FunctionMocker::replace( 'HCaptcha\AntiSpam\WP_Filesystem', $result ); + } + + /** + * Define namespaced ABSPATH for AntiSpam filesystem tests. + * + * @return void + */ + private function define_namespaced_abspath(): void { + $base_dir = str_replace( '\\', '/', sys_get_temp_dir() ) . '/hcaptcha-antispam-wp'; + $file = $base_dir . '/wp-admin/includes/file.php'; + + if ( ! is_dir( dirname( $file ) ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( dirname( $file ), 0777, true ); + } + + if ( ! file_exists( $file ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $file, "once()->andReturn( $response ); + WP_Mock::userFunction( 'is_wp_error' )->with( $response )->once()->andReturn( false ); + WP_Mock::userFunction( 'wp_remote_retrieve_response_code' )->with( $response )->once()->andReturn( 200 ); + WP_Mock::userFunction( 'wp_remote_retrieve_body' )->with( $response )->once()->andReturn( $body ); + } + + /** + * Mock blocklist path helpers. + * + * @param string $base_dir Upload base dir. + * + * @return string + */ + private function mock_blocklist_path( string $base_dir ): string { + $path = $base_dir . '/hcaptcha/disposable-email-blocklist.conf'; + + WP_Mock::userFunction( 'wp_upload_dir' )->once()->andReturn( [ 'basedir' => $base_dir ] ); + WP_Mock::userFunction( 'trailingslashit' )->with( $base_dir )->once()->andReturn( $base_dir . '/' ); + + return $path; + } } diff --git a/tests/php/unit/AntiSpam/HoneypotTest.php b/tests/php/unit/AntiSpam/HoneypotTest.php new file mode 100644 index 000000000..5bc7f1c99 --- /dev/null +++ b/tests/php/unit/AntiSpam/HoneypotTest.php @@ -0,0 +1,87 @@ +makePartial(); + $main->shouldReceive( 'settings' )->with()->once()->andReturn( null ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->once()->andReturn( $main ); + + self::assertSame( + [ + 'honeypot' => [], + 'fst' => [], + ], + Honeypot::get_protected_forms() + ); + } + + /** + * Test get_protected_forms(). + * + * @param bool $honeypot Honeypot status. + * @param bool $fst Form submit time status. + * + * @dataProvider dp_test_get_protected_forms + */ + public function test_get_protected_forms( bool $honeypot, bool $fst ): void { + $settings = Mockery::mock( Settings::class )->makePartial(); + $settings->shouldReceive( 'is_on' )->with( 'honeypot' )->once()->andReturn( $honeypot ); + $settings->shouldReceive( 'is_on' )->with( 'set_min_submit_time' )->once()->andReturn( $fst ); + + $main = Mockery::mock( Main::class )->makePartial(); + $main->shouldReceive( 'settings' )->with()->once()->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->once()->andReturn( $main ); + + $result = Honeypot::get_protected_forms(); + + self::assertSame( $honeypot, [] !== $result['honeypot'] ); + self::assertSame( $fst, [] !== $result['fst'] ); + self::assertSame( $honeypot, isset( $result['honeypot']['wp_status'] ) ); + self::assertSame( $fst, isset( $result['fst']['wp_status'] ) ); + } + + /** + * Data provider for test_get_protected_forms(). + * + * @return array + */ + public function dp_test_get_protected_forms(): array { + return [ + 'both off' => [ false, false ], + 'honeypot only' => [ true, false ], + 'fst only' => [ false, true ], + 'both on' => [ true, true ], + ]; + } +} diff --git a/tests/php/unit/AntiSpam/ProviderBaseTest.php b/tests/php/unit/AntiSpam/ProviderBaseTest.php new file mode 100644 index 000000000..e484e4313 --- /dev/null +++ b/tests/php/unit/AntiSpam/ProviderBaseTest.php @@ -0,0 +1,54 @@ +verify( [ 'result' => 'spam' ] ) ); + self::assertNull( $subject->verify( [] ) ); + } +} diff --git a/tests/php/unit/AutoVerify/AutoVerifyTest.php b/tests/php/unit/AutoVerify/AutoVerifyTest.php index 25feee790..6455b8f9c 100644 --- a/tests/php/unit/AutoVerify/AutoVerifyTest.php +++ b/tests/php/unit/AutoVerify/AutoVerifyTest.php @@ -68,6 +68,43 @@ public function test_init_and_init_hooks(): void { $subject->init(); } + /** + * Test add_default_id() without auto mode. + */ + public function test_add_default_id_without_auto_mode(): void { + $args = [ + 'auto' => false, + ]; + + $subject = new AutoVerify(); + + self::assertSame( $args, $subject->add_default_id( $args ) ); + } + + /** + * Test add_default_id() with auto mode. + */ + public function test_add_default_id_with_auto_mode(): void { + $args = [ + 'auto' => true, + ]; + + WP_Mock::userFunction( 'get_the_ID' )->with()->once()->andReturn( 7 ); + + $subject = new AutoVerify(); + $result = $subject->add_default_id( $args ); + + self::assertSame( + [ + 'auto' => true, + 'id' => [ + 'source' => [ AutoVerify::class ], + 'form_id' => 7, + ], + ], + $result + ); + } /** * Test content_filter() on the frontend. */ diff --git a/tests/php/unit/MainTest.php b/tests/php/unit/MainTest.php index a2af36200..a82153355 100644 --- a/tests/php/unit/MainTest.php +++ b/tests/php/unit/MainTest.php @@ -11,7 +11,10 @@ use HCaptcha\Admin\MaxMindDb; use HCaptcha\Main; use HCaptcha\Settings\Settings; +use HCaptcha\WP\Signup; use Mockery; +use ReflectionException; +use RuntimeException; use tad\FunctionMocker\FunctionMocker; use WP_Mock; @@ -122,6 +125,85 @@ public function test_register_recurring_actions_unschedules_events_cleanup_when_ $subject->register_recurring_actions(); } + /** + * Test get_client_country_code(). + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_get_client_country_code(): void { + $client_ip = '203.0.113.40'; + $error_ip = '203.0.113.41'; + + $db_path = WP_CONTENT_DIR . '/uploads/hcaptcha/GeoLite2-Country.mmdb'; + + FunctionMocker::replace( 'is_readable', true ); + + $reader = Mockery::mock( 'overload:HCaptcha\Vendors\GeoIp2\Database\Reader' ); + $reader->shouldReceive( '__construct' )->with( $db_path ); + $reader->shouldReceive( 'country' )->andReturnUsing( + static function ( string $ip ) use ( $client_ip, $error_ip ) { + if ( $error_ip === $ip ) { + throw new RuntimeException( 'Reader error.' ); + } + + self::assertSame( $client_ip, $ip ); + + return (object) [ + 'country' => (object) [ + 'isoCode' => ' us ', + ], + ]; + } + ); + $reader->shouldReceive( 'close' )->andReturnNull(); + + $subject = new Main(); + $method = $this->set_method_accessibility( $subject, 'get_client_country_code' ); + + self::assertSame( 'US', $method->invoke( $subject, $client_ip ) ); + self::assertSame( '', $method->invoke( $subject, $error_ip ) ); + } + + /** + * Test load_modules() on multisite. + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_load_modules_on_multisite(): void { + FunctionMocker::replace( 'is_multisite', true ); + FunctionMocker::replace( + 'function_exists', + static function ( $function_name ) { + return 'is_plugin_active' === $function_name; + } + ); + + $subject = new Main(); + + $this->set_protected_property( $subject, 'active', false ); + $subject->load_modules(); + + self::assertSame( + [ + [ 'wp_status', 'signup' ], + '', + Signup::class, + ], + $subject->modules['Signup Form'] + ); + self::assertSame( + [ + [ 'theme_my_login_status', 'signup' ], + 'theme-my-login/theme-my-login.php', + \HCaptcha\ThemeMyLogin\Signup::class, + ], + $subject->modules['Theme My Login Signup'] + ); + self::assertArrayNotHasKey( 'Theme My Login Register', $subject->modules ); + } + /** * Define the DAY_IN_SECONDS constant if WordPress has not defined it. * diff --git a/tests/php/unit/MigrationWizard/Detectors/ACFEDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/ACFEDetectorTest.php index e27dd507c..61c830bc8 100644 --- a/tests/php/unit/MigrationWizard/Detectors/ACFEDetectorTest.php +++ b/tests/php/unit/MigrationWizard/Detectors/ACFEDetectorTest.php @@ -30,9 +30,9 @@ class ACFEDetectorTest extends HCaptchaTestCase { private const PLUGIN_SLUG_LITE = 'acf-extended/acf-extended.php'; /** - * Test detect when reCAPTCHA keys are present in acfe_settings option. + * Test detect when reCAPTCHA keys are present in the acfe_settings option. */ - public function test_detect_with_keys_in_option() { + public function test_detect_with_keys_in_option(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -60,9 +60,9 @@ public function test_detect_with_keys_in_option() { } /** - * Test detect when reCAPTCHA keys are present via acf_get_setting. + * Tests detect when reCAPTCHA keys are present via acf_get_setting. */ - public function test_detect_with_keys_in_acf_get_setting() { + public function test_detect_with_keys_in_acf_get_setting(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -85,7 +85,7 @@ public function test_detect_with_keys_in_acf_get_setting() { /** * Test detect when no keys are present. */ - public function test_detect_no_keys() { + public function test_detect_no_keys(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -101,9 +101,9 @@ public function test_detect_no_keys() { } /** - * Test detect when only site key is present. + * Test detect when only a site key is present. */ - public function test_detect_only_site_key() { + public function test_detect_only_site_key(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -129,7 +129,7 @@ public function test_detect_only_site_key() { /** * Test detect with whitespace keys. */ - public function test_detect_with_whitespace_keys() { + public function test_detect_with_whitespace_keys(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -156,7 +156,7 @@ public function test_detect_with_whitespace_keys() { /** * Test is_applicable. */ - public function test_is_applicable() { + public function test_is_applicable(): void { // Lite active. WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) @@ -176,7 +176,7 @@ public function test_is_applicable() { /** * Test get_source_plugin. */ - public function test_get_source_plugin() { + public function test_get_source_plugin(): void { // Case 1: Pro is active. WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) @@ -185,4 +185,43 @@ public function test_get_source_plugin() { $detector = new ACFEDetector(); self::assertSame( self::PLUGIN_SLUG_PRO, $detector->get_source_plugin(), 'Should return Pro when Pro is active' ); } + + /** + * Test get_source_plugin defaults to Pro when no plugin is active. + */ + public function test_get_source_plugin_defaults_to_pro_when_no_plugin_active(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [] ); + + $detector = new ACFEDetector(); + + self::assertSame( 'acf-extended-pro/acf-extended.php', $detector->get_source_plugin() ); + } + + /** + * Test is_applicable returns false when neither ACFE plugin is active. + */ + public function test_is_applicable_when_plugin_inactive(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [] ); + + $detector = new ACFEDetector(); + + self::assertFalse( $detector->is_applicable() ); + } + + /** + * Test detect returns an empty array when settings are a non-array. + */ + public function test_detect_with_non_array_settings(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'acfe_settings', [] ) + ->andReturn( 'broken' ); + + $detector = new ACFEDetector(); + + self::assertSame( [], $detector->detect() ); + } } diff --git a/tests/php/unit/MigrationWizard/Detectors/AbstractDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/AbstractDetectorTest.php new file mode 100644 index 000000000..3b5d3f500 --- /dev/null +++ b/tests/php/unit/MigrationWizard/Detectors/AbstractDetectorTest.php @@ -0,0 +1,70 @@ +with( 'active_plugins', [] ) + ->andReturn( 'broken' ); + + $detector = new class() extends AbstractDetector { + + /** + * Get a source plugin. + * + * @return string + */ + public function get_source_plugin(): string { + return 'test/plugin.php'; + } + + /** + * Get the source name. + * + * @return string + */ + public function get_source_name(): string { + return 'Test Plugin'; + } + + /** + * Whether a detector is applicable. + * + * @return bool + */ + public function is_applicable(): bool { + return $this->is_plugin_active( 'test/plugin.php' ); + } + + /** + * Detect settings. + * + * @return array + */ + public function detect(): array { + return []; + } + }; + + self::assertFalse( $detector->is_applicable() ); + } +} diff --git a/tests/php/unit/MigrationWizard/Detectors/AdvancedNoCaptchaDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/AdvancedNoCaptchaDetectorTest.php new file mode 100644 index 000000000..fd6f597e9 --- /dev/null +++ b/tests/php/unit/MigrationWizard/Detectors/AdvancedNoCaptchaDetectorTest.php @@ -0,0 +1,129 @@ +get_source_plugin() + ); + } + + /** + * Test get_source_name. + */ + public function test_get_source_name(): void { + $detector = new AdvancedNoCaptchaDetector(); + + self::assertSame( 'Advanced noCaptcha & invisible Captcha', $detector->get_source_name() ); + } + + /** + * Test is_applicable returns true when the plugin is active. + */ + public function test_is_applicable_when_plugin_active(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [ 'advanced-nocaptcha-recaptcha/advanced-nocaptcha-recaptcha.php' ] ); + + $detector = new AdvancedNoCaptchaDetector(); + + self::assertTrue( $detector->is_applicable() ); + } + + /** + * Test is_applicable returns false when the plugin is inactive. + */ + public function test_is_applicable_when_plugin_inactive(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [] ); + + $detector = new AdvancedNoCaptchaDetector(); + + self::assertFalse( $detector->is_applicable() ); + } + + /** + * Test detect returns configured surfaces. + */ + public function test_detect_with_enabled_forms(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'c4wp_admin_options', [] ) + ->andReturn( + [ + 'enabled_forms' => [ + 'login', + 'registration', + 'lost_password', + 'comment', + 'wc_login', + 'wc_registration', + 'wc_checkout', + 'wc_lost_password', + 'bp_register', + 'bbp_topic', + 'bbp_reply', + ], + ] + ); + + $detector = new AdvancedNoCaptchaDetector(); + $results = $detector->detect(); + + self::assertCount( 11, $results ); + self::assertSame( 'wp_login', $results[0]->get_surface() ); + self::assertSame( 'recaptcha', $results[0]->get_provider() ); + self::assertTrue( $results[0]->is_migratable() ); + self::assertSame( 'buddypress_register', $results[8]->get_surface() ); + self::assertSame( DetectionResult::STATUS_UNSUPPORTED, $results[8]->get_support_status() ); + } + + /** + * Test detect returns an empty array for empty settings. + */ + public function test_detect_with_empty_settings(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'c4wp_admin_options', [] ) + ->andReturn( [] ); + + $detector = new AdvancedNoCaptchaDetector(); + + self::assertSame( [], $detector->detect() ); + } + + /** + * Test detect returns an empty array for non-array settings. + */ + public function test_detect_with_non_array_settings(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'c4wp_admin_options', [] ) + ->andReturn( 'broken' ); + + $detector = new AdvancedNoCaptchaDetector(); + + self::assertSame( [], $detector->detect() ); + } +} diff --git a/tests/php/unit/MigrationWizard/Detectors/CF7RecaptchaDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/CF7RecaptchaDetectorTest.php new file mode 100644 index 000000000..80902f5db --- /dev/null +++ b/tests/php/unit/MigrationWizard/Detectors/CF7RecaptchaDetectorTest.php @@ -0,0 +1,152 @@ +get_source_plugin() ); + } + + /** + * Test get_source_name. + */ + public function test_get_source_name(): void { + $detector = new CF7RecaptchaDetector(); + + self::assertSame( 'Contact Form 7 (native reCAPTCHA)', $detector->get_source_name() ); + } + + /** + * Test is_applicable returns true when the plugin is active. + */ + public function test_is_applicable_when_plugin_active(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [ 'contact-form-7/wp-contact-form-7.php' ] ); + + $detector = new CF7RecaptchaDetector(); + + self::assertTrue( $detector->is_applicable() ); + } + + /** + * Test is_applicable returns false when the plugin is inactive. + */ + public function test_is_applicable_when_plugin_inactive(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [] ); + + $detector = new CF7RecaptchaDetector(); + + self::assertFalse( $detector->is_applicable() ); + } + + /** + * Test detect returns CF7 surfaces when keys are set. + * + * @throws ReflectionException ReflectionException. + */ + public function test_detect_with_keys(): void { + $this->mock_utils_remove_action(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'wpcf7', [] ) + ->andReturn( + [ + 'recaptcha' => [ + 'site-key' => 'site-key', + 'secret' => 'secret-key', + ], + ] + ); + + $detector = new CF7RecaptchaDetector(); + $results = $detector->detect(); + + self::assertCount( 2, $results ); + self::assertSame( 'cf7_form', $results[0]->get_surface() ); + self::assertSame( 'cf7_embed', $results[1]->get_surface() ); + self::assertSame( 'recaptcha', $results[0]->get_provider() ); + self::assertSame( 'high', $results[0]->get_confidence() ); + } + + /** + * Test detect returns an empty array when keys are incomplete. + * + * @throws ReflectionException ReflectionException. + */ + public function test_detect_with_incomplete_keys(): void { + $this->mock_utils_remove_action(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'wpcf7', [] ) + ->andReturn( + [ + 'recaptcha' => [ + 'site-key' => '', + ], + ] + ); + + $detector = new CF7RecaptchaDetector(); + + self::assertSame( [], $detector->detect() ); + } + + /** + * Test detect returns an empty array for non-array settings. + * + * @throws ReflectionException ReflectionException. + */ + public function test_detect_with_non_array_settings(): void { + $this->mock_utils_remove_action(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'wpcf7', [] ) + ->andReturn( 'broken' ); + + $detector = new CF7RecaptchaDetector(); + + self::assertSame( [], $detector->detect() ); + } + + /** + * Mock Utils::remove_action_regex(). + * + * @throws ReflectionException ReflectionException. + */ + private function mock_utils_remove_action(): void { + $utils = Mockery::mock( Utils::class )->makePartial(); + + $utils->shouldReceive( 'remove_action_regex' ) + ->once() + ->with( '#^' . preg_quote( Base::class, '#' ) . '#', 'option_wpcf7' ); + + $this->set_protected_property( $utils, 'instance', $utils ); + } +} diff --git a/tests/php/unit/MigrationWizard/Detectors/GravityFormsRecaptchaDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/GravityFormsRecaptchaDetectorTest.php new file mode 100644 index 000000000..275f51d34 --- /dev/null +++ b/tests/php/unit/MigrationWizard/Detectors/GravityFormsRecaptchaDetectorTest.php @@ -0,0 +1,103 @@ +get_source_plugin() ); + } + + /** + * Test get_source_name. + */ + public function test_get_source_name(): void { + $detector = new GravityFormsRecaptchaDetector(); + + self::assertSame( 'Gravity Forms', $detector->get_source_name() ); + } + + /** + * Test is_applicable returns true when the plugin is active. + */ + public function test_is_applicable_when_plugin_active(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [ 'gravityforms/gravityforms.php' ] ); + + $detector = new GravityFormsRecaptchaDetector(); + + self::assertTrue( $detector->is_applicable() ); + } + + /** + * Test is_applicable returns false when the plugin is inactive. + */ + public function test_is_applicable_when_plugin_inactive(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [] ); + + $detector = new GravityFormsRecaptchaDetector(); + + self::assertFalse( $detector->is_applicable() ); + } + + /** + * Test detect returns Gravity Forms surfaces when keys are set. + */ + public function test_detect_with_keys(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'rg_gforms_captcha_public_key', '' ) + ->andReturn( 'site-key' ); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'rg_gforms_captcha_private_key', '' ) + ->andReturn( 'secret-key' ); + + $detector = new GravityFormsRecaptchaDetector(); + $results = $detector->detect(); + + self::assertCount( 2, $results ); + self::assertSame( 'gravity_form', $results[0]->get_surface() ); + self::assertSame( 'gravity_embed', $results[1]->get_surface() ); + self::assertSame( 'recaptcha', $results[0]->get_provider() ); + self::assertSame( 'medium', $results[0]->get_confidence() ); + } + + /** + * Test detect returns an empty array when keys are incomplete. + */ + public function test_detect_with_incomplete_keys(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'rg_gforms_captcha_public_key', '' ) + ->andReturn( 'site-key' ); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'rg_gforms_captcha_private_key', '' ) + ->andReturn( '' ); + + $detector = new GravityFormsRecaptchaDetector(); + + self::assertSame( [], $detector->detect() ); + } +} diff --git a/tests/php/unit/MigrationWizard/Detectors/SimpleTurnstileDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/SimpleTurnstileDetectorTest.php new file mode 100644 index 000000000..840bb407d --- /dev/null +++ b/tests/php/unit/MigrationWizard/Detectors/SimpleTurnstileDetectorTest.php @@ -0,0 +1,145 @@ +get_source_plugin() ); + } + + /** + * Test get_source_name. + */ + public function test_get_source_name(): void { + $detector = new SimpleTurnstileDetector(); + + self::assertSame( 'Simple Cloudflare Turnstile', $detector->get_source_name() ); + } + + /** + * Test is_applicable returns true when the plugin is active. + */ + public function test_is_applicable_when_plugin_active(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [ 'simple-cloudflare-turnstile/simple-cloudflare-turnstile.php' ] ); + + $detector = new SimpleTurnstileDetector(); + + self::assertTrue( $detector->is_applicable() ); + } + + /** + * Test is_applicable returns false when the plugin is inactive. + */ + public function test_is_applicable_when_plugin_inactive(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [] ); + + $detector = new SimpleTurnstileDetector(); + + self::assertFalse( $detector->is_applicable() ); + } + + /** + * Test detect returns every enabled unique surface. + */ + public function test_detect_with_enabled_options(): void { + $enabled = [ + 'cfturnstile_login' => '1', + 'cfturnstile_register' => '1', + 'cfturnstile_reset' => '1', + 'cfturnstile_comment' => '1', + 'cfturnstile_bbpress_create' => '1', + 'cfturnstile_bbpress_reply' => '1', + 'cfturnstile_bp_register' => '1', + 'cfturnstile_cf7_all' => '1', + 'cfturnstile_edd_checkout' => '1', + 'cfturnstile_edd_login' => '1', + 'cfturnstile_edd_register' => '1', + 'cfturnstile_elementor' => '1', + 'cfturnstile_fluent' => '1', + 'cfturnstile_formidable' => '1', + 'cfturnstile_forminator' => '1', + 'cfturnstile_gravity' => '1', + 'cfturnstile_jetpack' => '1', + 'cfturnstile_kadence' => '1', + 'cfturnstile_mailpoet' => '1', + 'cfturnstile_mepr_login' => '1', + 'cfturnstile_mepr_register' => '1', + 'cfturnstile_pmp_checkout' => '1', + 'cfturnstile_pmp_login' => '1', + 'cfturnstile_um_login' => '1', + 'cfturnstile_um_password' => '1', + 'cfturnstile_um_register' => '1', + 'cfturnstile_woo_login' => '1', + 'cfturnstile_woo_register' => '1', + 'cfturnstile_woo_checkout' => '1', + 'cfturnstile_woo_checkout_pay' => '1', + 'cfturnstile_woo_reset' => '1', + 'cfturnstile_wpforms' => '1', + ]; + + WP_Mock::userFunction( 'get_option' ) + ->andReturnUsing( + static function ( $option, $default_value = '' ) use ( $enabled ) { + return $enabled[ $option ] ?? $default_value; + } + ); + + $detector = new SimpleTurnstileDetector(); + $results = $detector->detect(); + $surfaces = array_map( + static function ( $result ) { + return $result->get_surface(); + }, + $results + ); + + self::assertCount( 35, $results ); + self::assertSame( $surfaces, array_values( array_unique( $surfaces ) ) ); + self::assertContains( 'cf7_form', $surfaces ); + self::assertContains( 'gravity_embed', $surfaces ); + self::assertContains( 'wpforms_embed', $surfaces ); + self::assertContains( 'wc_checkout', $surfaces ); + self::assertSame( 'turnstile', $results[0]->get_provider() ); + self::assertSame( 'medium', $results[0]->get_confidence() ); + } + + /** + * Test detect returns an empty array when options are disabled. + */ + public function test_detect_with_disabled_options(): void { + WP_Mock::userFunction( 'get_option' ) + ->andReturnUsing( + static function ( $option, $default_value = '' ) { + return $default_value; + } + ); + + $detector = new SimpleTurnstileDetector(); + + self::assertSame( [], $detector->detect() ); + } +} diff --git a/tests/php/unit/MigrationWizard/Detectors/WPFormsRecaptchaDetectorTest.php b/tests/php/unit/MigrationWizard/Detectors/WPFormsRecaptchaDetectorTest.php index 32f862926..a3b2e5067 100644 --- a/tests/php/unit/MigrationWizard/Detectors/WPFormsRecaptchaDetectorTest.php +++ b/tests/php/unit/MigrationWizard/Detectors/WPFormsRecaptchaDetectorTest.php @@ -7,7 +7,6 @@ namespace HCaptcha\Tests\Unit\MigrationWizard\Detectors; -use HCaptcha\MigrationWizard\DetectionResult; use HCaptcha\MigrationWizard\Detectors\WPFormsRecaptchaDetector; use HCaptcha\Tests\Unit\HCaptchaTestCase; use WP_Mock; @@ -32,7 +31,7 @@ class WPFormsRecaptchaDetectorTest extends HCaptchaTestCase { /** * Test detect with reCAPTCHA keys. */ - public function test_detect_with_recaptcha() { + public function test_detect_with_recaptcha(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -60,7 +59,7 @@ public function test_detect_with_recaptcha() { /** * Test detect with Turnstile keys. */ - public function test_detect_with_turnstile() { + public function test_detect_with_turnstile(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_PRO ] ); @@ -85,7 +84,7 @@ public function test_detect_with_turnstile() { /** * Test detect with no settings. */ - public function test_detect_no_settings() { + public function test_detect_no_settings(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -103,7 +102,7 @@ public function test_detect_no_settings() { /** * Test detect with other provider (e.g. hcaptcha). */ - public function test_detect_with_hcaptcha_provider() { + public function test_detect_with_hcaptcha_provider(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -125,7 +124,7 @@ public function test_detect_with_hcaptcha_provider() { /** * Test detect with missing keys. */ - public function test_detect_missing_keys() { + public function test_detect_missing_keys(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -149,7 +148,7 @@ public function test_detect_missing_keys() { /** * Test is_applicable. */ - public function test_is_applicable_lite() { + public function test_is_applicable_lite(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); @@ -160,7 +159,7 @@ public function test_is_applicable_lite() { /** * Test is_applicable pro. */ - public function test_is_applicable_pro() { + public function test_is_applicable_pro(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_PRO ] ); @@ -171,7 +170,7 @@ public function test_is_applicable_pro() { /** * Test is_applicable none. */ - public function test_is_applicable_none() { + public function test_is_applicable_none(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [] ); @@ -182,7 +181,7 @@ public function test_is_applicable_none() { /** * Test get_source_plugin pro. */ - public function test_get_source_plugin_pro() { + public function test_get_source_plugin_pro(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_PRO ] ); @@ -193,11 +192,23 @@ public function test_get_source_plugin_pro() { /** * Test get_source_plugin lite. */ - public function test_get_source_plugin_lite() { + public function test_get_source_plugin_lite(): void { WP_Mock::userFunction( 'get_option' ) ->with( 'active_plugins', [] ) ->andReturn( [ self::PLUGIN_SLUG_LITE ] ); $detector = new WPFormsRecaptchaDetector(); self::assertSame( self::PLUGIN_SLUG_LITE, $detector->get_source_plugin(), 'Should return Lite when Lite is active' ); } + /** + * Test get_source_plugin defaults to Pro when no plugin is active. + */ + public function test_get_source_plugin_defaults_to_pro_when_no_plugin_active(): void { + WP_Mock::userFunction( 'get_option' ) + ->with( 'active_plugins', [] ) + ->andReturn( [] ); + + $detector = new WPFormsRecaptchaDetector(); + + self::assertSame( 'wpforms/wpforms.php', $detector->get_source_plugin() ); + } } diff --git a/tests/php/unit/MigrationWizard/MigrationWizardControllerTest.php b/tests/php/unit/MigrationWizard/MigrationWizardControllerTest.php new file mode 100644 index 000000000..196c626e1 --- /dev/null +++ b/tests/php/unit/MigrationWizard/MigrationWizardControllerTest.php @@ -0,0 +1,676 @@ +init(); + } + + /** + * Test admin_enqueue_scripts(). + */ + public function test_admin_enqueue_scripts(): void { + $plugin_url = 'https://example.test/wp-content/plugins/hcaptcha-wordpress-plugin'; + $plugin_version = '1.2.3'; + $min = '.min'; + $ajax_url = 'https://example.test/wp-admin/admin-ajax.php'; + $scan_nonce = 'scan-nonce'; + $apply_nonce = 'apply-nonce'; + + FunctionMocker::replace( + 'constant', + static function ( $name ) use ( $plugin_url, $plugin_version ) { + if ( 'HCAPTCHA_URL' === $name ) { + return $plugin_url; + } + + if ( 'HCAPTCHA_VERSION' === $name ) { + return $plugin_version; + } + + return null; + } + ); + + WP_Mock::passthruFunction( '__' ); + WP_Mock::userFunction( 'hcap_min_suffix' )->with()->once()->andReturn( $min ); + + WP_Mock::userFunction( 'wp_enqueue_script' ) + ->with( + MigrationWizard::DIALOG_HANDLE, + $plugin_url . "/assets/js/kagg-dialog$min.js", + [], + $plugin_version, + true + ) + ->once(); + + WP_Mock::userFunction( 'wp_enqueue_style' ) + ->with( + MigrationWizard::DIALOG_HANDLE, + $plugin_url . "/assets/css/kagg-dialog$min.css", + [], + $plugin_version + ) + ->once(); + + WP_Mock::userFunction( 'wp_enqueue_style' ) + ->with( + MigrationWizard::HANDLE, + $plugin_url . "/assets/css/migration-wizard$min.css", + [ MigrationWizard::DIALOG_HANDLE ], + $plugin_version + ) + ->once(); + + WP_Mock::userFunction( 'wp_enqueue_script' ) + ->with( + MigrationWizard::HANDLE, + $plugin_url . "/assets/js/migration-wizard$min.js", + [ MigrationWizard::DIALOG_HANDLE ], + $plugin_version, + true + ) + ->once(); + + WP_Mock::userFunction( 'admin_url' ) + ->with( 'admin-ajax.php' ) + ->once() + ->andReturn( $ajax_url ); + + WP_Mock::userFunction( 'wp_create_nonce' ) + ->with( 'hcaptcha_migration_scan_nonce' ) + ->once() + ->andReturn( $scan_nonce ); + + WP_Mock::userFunction( 'wp_create_nonce' ) + ->with( 'hcaptcha_migration_apply_nonce' ) + ->once() + ->andReturn( $apply_nonce ); + + WP_Mock::userFunction( 'wp_localize_script' ) + ->with( + MigrationWizard::HANDLE, + MigrationWizard::OBJECT, + Mockery::on( + static function ( $data ) use ( $ajax_url, $scan_nonce, $apply_nonce ) { + return $ajax_url === $data['ajaxUrl'] + && MigrationWizard::SCAN_ACTION === $data['scanAction'] + && $scan_nonce === $data['scanNonce'] + && MigrationWizard::APPLY_ACTION === $data['applyAction'] + && $apply_nonce === $data['applyNonce'] + && 'OK' === $data['i18n']['okBtnText'] + && 'reCAPTCHA' === $data['i18n']['providerRecaptcha'] + && 'Turnstile' === $data['i18n']['providerTurnstile'] + && isset( $data['i18n']['foundSurfaces'][0], $data['i18n']['migratableCount'][1] ); + } + ) + ) + ->once(); + + $subject = new MigrationWizard(); + + $subject->admin_enqueue_scripts(); + } + + /** + * Test render_section(). + * + * @noinspection JsonEncodingApiUsageInspection + */ + public function test_render_section(): void { + $saved_state = [ + 'scan_data' => [ + 'total' => 1, + ], + ]; + $settings = Mockery::mock( Settings::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); + + $settings->shouldReceive( 'get' )->with( 'site_key' )->once()->andReturn( 'site-key' ); + $settings->shouldReceive( 'get' )->with( 'secret_key' )->once()->andReturn( 'secret-key' ); + $settings->shouldReceive( 'tab_url' )->with( General::class )->once()->andReturn( 'https://example.test/general' ); + $settings->shouldReceive( 'tab_url' )->with( Integrations::class )->once()->andReturn( 'https://example.test/integrations' ); + $main->shouldReceive( 'settings' )->with()->once()->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->once()->andReturn( $main ); + WP_Mock::userFunction( 'get_transient' ) + ->with( MigrationWizard::STATE_TRANSIENT ) + ->once() + ->andReturn( $saved_state ); + WP_Mock::passthruFunction( 'esc_url' ); + WP_Mock::passthruFunction( 'esc_attr' ); + WP_Mock::passthruFunction( 'esc_html__' ); + WP_Mock::userFunction( 'wp_json_encode' ) + ->with( $saved_state ) + ->once() + ->andReturnUsing( + static function ( $data ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Test fixture for mocked wp_json_encode(). + return json_encode( $data ); + } + ); + WP_Mock::userFunction( 'esc_html_e' ) + ->andReturnUsing( + static function ( $text ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $text; + } + ); + + $subject = new MigrationWizard(); + + ob_start(); + $subject->render_section(); + $output = ob_get_clean(); + + self::assertStringContainsString( 'id="hcaptcha-migration-wizard"', $output ); + self::assertStringContainsString( 'data-has-keys="1"', $output ); + self::assertStringContainsString( 'data-settings-url="https://example.test/general"', $output ); + self::assertStringContainsString( 'Scan Site', $output ); + self::assertStringContainsString( 'View Integrations', $output ); + } + + /** + * Test ajax_scan() with invalid nonce. + */ + public function test_ajax_scan_with_invalid_nonce(): void { + WP_Mock::passthruFunction( '__' ); + WP_Mock::userFunction( 'check_ajax_referer' ) + ->with( 'hcaptcha_migration_scan_nonce', 'nonce', false ) + ->once() + ->andReturn( false ); + WP_Mock::userFunction( 'wp_send_json_error' ) + ->with( [ 'message' => 'Security check failed.' ] ) + ->once() + ->andThrow( new RuntimeException( 'json-error' ) ); + + $this->expectException( RuntimeException::class ); + + $subject = new MigrationWizard(); + + $subject->ajax_scan(); + } + + /** + * Test ajax_scan() with insufficient permissions. + */ + public function test_ajax_scan_with_insufficient_permissions(): void { + WP_Mock::passthruFunction( '__' ); + WP_Mock::userFunction( 'check_ajax_referer' ) + ->with( 'hcaptcha_migration_scan_nonce', 'nonce', false ) + ->once() + ->andReturn( true ); + WP_Mock::userFunction( 'current_user_can' ) + ->with( MigrationWizard::CAPABILITY ) + ->once() + ->andReturn( false ); + WP_Mock::userFunction( 'wp_send_json_error' ) + ->with( [ 'message' => 'Insufficient permissions.' ] ) + ->once() + ->andThrow( new RuntimeException( 'json-error' ) ); + + $this->expectException( RuntimeException::class ); + + $subject = new MigrationWizard(); + + $subject->ajax_scan(); + } + + /** + * Test ajax_scan() success. + */ + public function test_ajax_scan_success(): void { + $data = [ 'total' => 0 ]; + $subject = Mockery::mock( MigrationWizard::class )->makePartial(); + + $subject->shouldReceive( 'scan' )->with()->once()->andReturn( $data ); + + WP_Mock::userFunction( 'check_ajax_referer' ) + ->with( 'hcaptcha_migration_scan_nonce', 'nonce', false ) + ->once() + ->andReturn( true ); + WP_Mock::userFunction( 'current_user_can' ) + ->with( MigrationWizard::CAPABILITY ) + ->once() + ->andReturn( true ); + WP_Mock::userFunction( 'wp_send_json_success' )->with( $data )->once(); + + $subject->ajax_scan(); + } + + /** + * Test ajax_apply() with invalid nonce. + */ + public function test_ajax_apply_with_invalid_nonce(): void { + WP_Mock::passthruFunction( '__' ); + WP_Mock::userFunction( 'check_ajax_referer' ) + ->with( 'hcaptcha_migration_apply_nonce', 'nonce', false ) + ->once() + ->andReturn( false ); + WP_Mock::userFunction( 'wp_send_json_error' ) + ->with( [ 'message' => 'Security check failed.' ] ) + ->once() + ->andThrow( new RuntimeException( 'json-error' ) ); + + $this->expectException( RuntimeException::class ); + + $subject = new MigrationWizard(); + + $subject->ajax_apply(); + } + + /** + * Test ajax_apply() with insufficient permissions. + */ + public function test_ajax_apply_with_insufficient_permissions(): void { + WP_Mock::passthruFunction( '__' ); + WP_Mock::userFunction( 'check_ajax_referer' ) + ->with( 'hcaptcha_migration_apply_nonce', 'nonce', false ) + ->once() + ->andReturn( true ); + WP_Mock::userFunction( 'current_user_can' ) + ->with( MigrationWizard::CAPABILITY ) + ->once() + ->andReturn( false ); + WP_Mock::userFunction( 'wp_send_json_error' ) + ->with( [ 'message' => 'Insufficient permissions.' ] ) + ->once() + ->andThrow( new RuntimeException( 'json-error' ) ); + + $this->expectException( RuntimeException::class ); + + $subject = new MigrationWizard(); + + $subject->ajax_apply(); + } + + /** + * Test ajax_apply() without surfaces. + */ + public function test_ajax_apply_without_surfaces(): void { + unset( $_POST['surfaces'] ); + + WP_Mock::passthruFunction( '__' ); + $this->mock_wp_error_class( 'no_surfaces_selected', 'No surfaces selected.' ); + $this->mock_is_wp_error(); + $this->mock_successful_apply_checks(); + WP_Mock::userFunction( 'wp_send_json_error' ) + ->with( [ 'message' => 'No surfaces selected.' ] ) + ->once(); + + $subject = new MigrationWizard(); + + $subject->ajax_apply(); + } + + /** + * Test ajax_apply() with invalid surfaces data. + */ + public function test_ajax_apply_with_invalid_surfaces_data(): void { + $_POST['surfaces'] = 'not-json'; + + WP_Mock::passthruFunction( '__' ); + WP_Mock::passthruFunction( 'wp_unslash' ); + WP_Mock::passthruFunction( 'sanitize_text_field' ); + $this->mock_wp_error_class( 'invalid_surfaces_data', 'Invalid surfaces data.' ); + $this->mock_is_wp_error(); + $this->mock_successful_apply_checks(); + WP_Mock::userFunction( 'wp_send_json_error' ) + ->with( [ 'message' => 'Invalid surfaces data.' ] ) + ->once(); + + $subject = new MigrationWizard(); + + $subject->ajax_apply(); + } + + /** + * Test ajax_apply() when apply() returns an error. + * + * @noinspection JsonEncodingApiUsageInspection + */ + public function test_ajax_apply_with_apply_error(): void { + $surfaces = $this->get_request_surfaces(); + $error = new class() { + + /** + * Get error message. + * + * @return string + */ + public function get_error_message(): string { + return 'Apply failed.'; + } + }; + $subject = Mockery::mock( MigrationWizard::class )->makePartial(); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Test request fixture. + $_POST['surfaces'] = json_encode( $surfaces ); + + $subject->shouldReceive( 'apply' )->with( $surfaces )->once()->andReturn( $error ); + + WP_Mock::passthruFunction( 'wp_unslash' ); + WP_Mock::passthruFunction( 'sanitize_text_field' ); + $this->mock_is_wp_error(); + $this->mock_successful_apply_checks(); + WP_Mock::userFunction( 'wp_send_json_error' ) + ->with( [ 'message' => 'Apply failed.' ] ) + ->once(); + + $subject->ajax_apply(); + } + + /** + * Test ajax_apply() success. + * + * @noinspection JsonEncodingApiUsageInspection + */ + public function test_ajax_apply_success(): void { + $surfaces = $this->get_request_surfaces(); + $result = [ + 'enabled' => [ 'wp_login', 'wp_register' ], + 'failed' => [ 'bad_surface' ], + ]; + $subject = Mockery::mock( MigrationWizard::class )->makePartial(); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Test request fixture. + $_POST['surfaces'] = json_encode( $surfaces ); + + $subject->shouldReceive( 'apply' )->with( $surfaces )->once()->andReturn( $result ); + + WP_Mock::passthruFunction( 'wp_unslash' ); + WP_Mock::passthruFunction( 'sanitize_text_field' ); + $this->mock_is_wp_error(); + $this->mock_successful_apply_checks(); + FunctionMocker::replace( + 'HCaptcha\MigrationWizard\_n', + static function ( $single, $plural ) { + return $plural; + } + ); + WP_Mock::userFunction( 'wp_send_json_success' ) + ->with( + [ + 'enabled' => [ 'wp_login', 'wp_register' ], + 'failed' => [ 'bad_surface' ], + 'message' => 'Successfully enabled hCaptcha on 2 surfaces.', + ] + ) + ->once(); + + $subject->ajax_apply(); + } + + /** + * Test apply() with empty surfaces. + */ + public function test_apply_with_empty_surfaces(): void { + WP_Mock::passthruFunction( '__' ); + $this->mock_wp_error_class( 'no_surfaces_selected', 'No surfaces selected.' ); + + $subject = new MigrationWizard(); + $result = $subject->apply( [] ); + + self::assertSame( 'no_surfaces_selected', $result->get_error_code() ); + } + + /** + * Test apply() without hCaptcha keys. + */ + public function test_apply_without_keys(): void { + $settings = Mockery::mock( Settings::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); + + $settings->shouldReceive( 'get' )->with( 'site_key' )->once()->andReturn( '' ); + $settings->shouldReceive( 'get' )->with( 'secret_key' )->once()->andReturn( 'secret-key' ); + $main->shouldReceive( 'settings' )->with()->once()->andReturn( $settings ); + + WP_Mock::passthruFunction( '__' ); + $this->mock_wp_error_class( 'keys_not_configured', 'hCaptcha keys are not configured.' ); + WP_Mock::userFunction( 'hcaptcha' )->with()->once()->andReturn( $main ); + $this->mock_is_wp_error(); + + $subject = new MigrationWizard(); + $result = $subject->apply( $this->get_request_surfaces() ); + + self::assertSame( 'keys_not_configured', $result->get_error_code() ); + } + + /** + * Test apply() with invalid and already enabled surfaces. + */ + public function test_apply_with_invalid_and_already_enabled_surfaces(): void { + $settings = Mockery::mock( Settings::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); + + $settings->shouldReceive( 'get' )->with( 'site_key' )->once()->andReturn( 'site-key' ); + $settings->shouldReceive( 'get' )->with( 'secret_key' )->once()->andReturn( 'secret-key' ); + $settings->shouldReceive( 'get' )->with( 'wp_status' )->once()->andReturn( [ 'login' ] ); + $main->shouldReceive( 'settings' )->with()->twice()->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->twice()->andReturn( $main ); + WP_Mock::passthruFunction( 'sanitize_text_field' ); + WP_Mock::userFunction( 'get_transient' ) + ->with( MigrationWizard::STATE_TRANSIENT ) + ->once() + ->andReturn( false ); + $this->mock_is_wp_error(); + + $subject = new MigrationWizard(); + $result = $subject->apply( + [ + [], + [ + 'surface' => 'unknown_surface', + 'hcaptcha_option_key' => 'wp_status', + 'hcaptcha_option_value' => 'login', + ], + [ + 'surface' => 'wp_login', + 'hcaptcha_option_key' => 'wp_status', + 'hcaptcha_option_value' => 'register', + ], + [ + 'surface' => 'wp_login', + 'hcaptcha_option_key' => 'wp_status', + 'hcaptcha_option_value' => 'login', + ], + ] + ); + + self::assertSame( + [ + 'enabled' => [ 'wp_login' ], + 'failed' => [ 'unknown', 'unknown_surface', 'wp_login' ], + ], + $result + ); + } + + /** + * Test scan() with non-array hCaptcha settings. + */ + public function test_scan_with_non_array_settings(): void { + $detection = new DetectionResult( + [ + 'surface' => 'wp_login', + 'hcaptcha_option_key' => 'wp_status', + 'hcaptcha_option_value' => 'login', + ] + ); + $scanner = Mockery::mock( Scanner::class ); + $settings = Mockery::mock(); + $main = Mockery::mock(); + $subject = Mockery::mock( MigrationWizard::class )->makePartial(); + + $scanner->shouldReceive( 'scan' )->once()->andReturn( new ScanResult( [ $detection ], [], [] ) ); + $settings->shouldReceive( 'get_raw_settings' )->with()->once()->andReturn( 'broken' ); + $main->shouldReceive( 'settings' )->with()->once()->andReturn( $settings ); + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'create_scanner' )->once()->andReturn( $scanner ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->once()->andReturn( $main ); + WP_Mock::userFunction( 'set_transient' ) + ->with( + MigrationWizard::STATE_TRANSIENT, + Mockery::on( + static function ( $state ) { + return [] === $state['scan_data']['already_enabled']; + } + ), + MigrationWizard::STATE_EXPIRATION + ) + ->once(); + + $result = $subject->scan(); + + self::assertSame( [], $result['already_enabled'] ); + } + + /** + * Test scan() skips results without mapping data. + */ + public function test_scan_skips_results_without_mapping_data(): void { + $scanner = Mockery::mock( Scanner::class ); + $settings = Mockery::mock( Settings::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); + $subject = Mockery::mock( MigrationWizard::class )->makePartial(); + $results = [ + new DetectionResult( [ 'surface' => 'unknown_surface' ] ), + new DetectionResult( + [ + 'surface' => 'wp_register', + 'hcaptcha_option_key' => 'wp_status', + 'hcaptcha_option_value' => 'register', + ] + ), + ]; + + $scanner->shouldReceive( 'scan' )->once()->andReturn( new ScanResult( $results, [], [] ) ); + $settings->shouldReceive( 'get_raw_settings' ) + ->with() + ->once() + ->andReturn( [ 'wp_status' => [ 'login' ] ] ); + $main->shouldReceive( 'settings' )->with()->once()->andReturn( $settings ); + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'create_scanner' )->once()->andReturn( $scanner ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->once()->andReturn( $main ); + WP_Mock::userFunction( 'set_transient' ) + ->with( + MigrationWizard::STATE_TRANSIENT, + Mockery::on( + static function ( $state ) { + return [] === $state['scan_data']['already_enabled']; + } + ), + MigrationWizard::STATE_EXPIRATION + ) + ->once(); + + $result = $subject->scan(); + + self::assertSame( [], $result['already_enabled'] ); + } + + /** + * Get request surfaces. + * + * @return array + */ + private function get_request_surfaces(): array { + return [ + [ + 'surface' => 'wp_login', + 'hcaptcha_option_key' => 'wp_status', + 'hcaptcha_option_value' => 'login', + ], + ]; + } + + /** + * Mock successfully apply AJAX security checks. + */ + private function mock_successful_apply_checks(): void { + WP_Mock::userFunction( 'check_ajax_referer' ) + ->with( 'hcaptcha_migration_apply_nonce', 'nonce', false ) + ->once() + ->andReturn( true ); + WP_Mock::userFunction( 'current_user_can' ) + ->with( MigrationWizard::CAPABILITY ) + ->once() + ->andReturn( true ); + } + + /** + * Mock WP_Error class. + * + * @param string $code Error code. + * @param string $message Error message. + */ + private function mock_wp_error_class( string $code, string $message ): void { + $wp_error = Mockery::mock( 'overload:WP_Error' ); + + $wp_error->shouldReceive( 'get_error_code' )->andReturn( $code ); + $wp_error->shouldReceive( 'get_error_message' )->andReturn( $message ); + } + + /** + * Mock is_wp_error(). + */ + private function mock_is_wp_error(): void { + WP_Mock::userFunction( 'is_wp_error' ) + ->andReturnUsing( + static function ( $thing ) { + return is_object( $thing ) && + ( is_a( $thing, 'WP_Error', true ) || method_exists( $thing, 'get_error_message' ) ); + } + ); + } +} diff --git a/tests/php/unit/Settings/EventsPageTest.php b/tests/php/unit/Settings/EventsPageTest.php index 104a11439..cab2d3e61 100644 --- a/tests/php/unit/Settings/EventsPageTest.php +++ b/tests/php/unit/Settings/EventsPageTest.php @@ -8,6 +8,7 @@ namespace HCaptcha\Tests\Unit\Settings; use HCaptcha\Admin\Events\EventsTable; +use HCaptcha\Admin\Events\Events; use HCaptcha\Main; use HCaptcha\Settings\EventsPage; use HCaptcha\Settings\ListPageBase; @@ -826,4 +827,176 @@ static function ( $ids ) { self::assertTrue( $subject->delete_events( [ 'ids' => $ids ] ) ); } + /** + * Test trash_events() when the trash schema is not ready. + * + * @return void + */ + public function test_trash_events_when_schema_is_not_ready(): void { + $subject = Mockery::mock( EventsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [ '5.0.0' => -1 ] ); + + self::assertFalse( $subject->trash_events( [ 'ids' => [ 1 ] ] ) ); + } + + /** + * Test restore_events() when the trash schema is not ready. + * + * @return void + */ + public function test_restore_events_when_schema_is_not_ready(): void { + $subject = Mockery::mock( EventsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [ '5.0.0' => -1 ] ); + + self::assertFalse( $subject->restore_events( [ 'ids' => [ 1 ] ] ) ); + } + + /** + * Test trash_events(). + * + * @return void + */ + public function test_trash_events(): void { + global $wpdb; + + $ids = [ 1, 2 ]; + $in = implode( ',', $ids ); + $prefix = 'wp_'; + $table_name = $prefix . Events::TABLE_NAME; + $trashed_at_gmt = '2026-06-28 12:00:00'; + $sql = "UPDATE $table_name SET status = %s, trashed_at_gmt = %s WHERE id IN($in) AND status = %s"; + $prepared = 'prepared trash events query'; + + FunctionMocker::replace( + 'HCaptcha\Helpers\DB::prepare_in', + static function ( $ids ) { + return implode( ',', $ids ); + } + ); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [] ); + WP_Mock::userFunction( 'current_time' ) + ->with( 'mysql', true ) + ->andReturn( $trashed_at_gmt ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = Mockery::mock( 'WPDB' ); + $wpdb->prefix = $prefix; + $wpdb->shouldReceive( 'prepare' ) + ->with( $sql, Events::STATUS_TRASH, $trashed_at_gmt, Events::STATUS_ACTIVE ) + ->andReturn( $prepared ); + $wpdb->shouldReceive( 'query' )->with( $prepared )->andReturn( count( $ids ) ); + + $subject = Mockery::mock( EventsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + self::assertTrue( $subject->trash_events( [ 'ids' => $ids ] ) ); + } + + /** + * Test restore_events(). + * + * @return void + */ + public function test_restore_events(): void { + global $wpdb; + + $ids = [ 1, 2 ]; + $in = implode( ',', $ids ); + $prefix = 'wp_'; + $table_name = $prefix . Events::TABLE_NAME; + $sql = "UPDATE $table_name SET status = %s, trashed_at_gmt = NULL WHERE id IN($in) AND status = %s"; + $prepared = 'prepared restore events query'; + + FunctionMocker::replace( + 'HCaptcha\Helpers\DB::prepare_in', + static function ( $ids ) { + return implode( ',', $ids ); + } + ); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [] ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = Mockery::mock( 'WPDB' ); + $wpdb->prefix = $prefix; + $wpdb->shouldReceive( 'prepare' ) + ->with( $sql, Events::STATUS_ACTIVE, Events::STATUS_TRASH ) + ->andReturn( $prepared ); + $wpdb->shouldReceive( 'query' )->with( $prepared )->andReturn( count( $ids ) ); + + $subject = Mockery::mock( EventsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + self::assertTrue( $subject->restore_events( [ 'ids' => $ids ] ) ); + } + /** + * Test delete_events() when the trash schema is not ready. + * + * @return void + */ + public function test_delete_events_when_schema_is_not_ready(): void { + $subject = Mockery::mock( EventsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [ '5.0.0' => -1 ] ); + + self::assertFalse( $subject->delete_events( [ 'ids' => [ 1 ] ] ) ); + } + + /** + * Test delete_events() with empty ids. + * + * @return void + */ + public function test_delete_events_with_empty_ids(): void { + global $wpdb; + + $prefix = 'wp_'; + + FunctionMocker::replace( 'HCaptcha\Helpers\DB::prepare_in', '' ); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [] ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = Mockery::mock( 'WPDB' ); + $wpdb->prefix = $prefix; + + $subject = Mockery::mock( EventsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + self::assertFalse( $subject->delete_events( [] ) ); + } + + /** + * Test trash_events() with empty ids. + * + * @return void + */ + public function test_trash_events_with_empty_ids(): void { + $subject = Mockery::mock( EventsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [] ); + + self::assertFalse( $subject->trash_events( [] ) ); + } } diff --git a/tests/php/unit/Settings/FormsPageTest.php b/tests/php/unit/Settings/FormsPageTest.php index b78120089..9560d1137 100644 --- a/tests/php/unit/Settings/FormsPageTest.php +++ b/tests/php/unit/Settings/FormsPageTest.php @@ -698,4 +698,212 @@ public function test_delete_events(): void { self::assertFalse( $subject->delete_events( [] ) ); self::assertTrue( $subject->delete_events( $args ) ); } + /** + * Test trash_events() when the trash schema is not ready. + * + * @return void + */ + public function test_trash_events_when_schema_is_not_ready(): void { + $subject = Mockery::mock( FormsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [ '5.0.0' => -1 ] ); + + self::assertFalse( + $subject->trash_events( + [ + 'ids' => [ + [ + 'source' => 'WordPress', + 'formId' => 'login', + ], + ], + ] + ) + ); + } + + /** + * Test restore_events() when the trash schema is not ready. + * + * @return void + */ + public function test_restore_events_when_schema_is_not_ready(): void { + $subject = Mockery::mock( FormsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [ '5.0.0' => -1 ] ); + + self::assertFalse( + $subject->restore_events( + [ + 'ids' => [ + [ + 'source' => 'WordPress', + 'formId' => 'login', + ], + ], + ] + ) + ); + } + + /** + * Test trash_events(). + * + * @return void + */ + public function test_trash_events(): void { + global $wpdb; + + $ids = [ + [ + 'source' => 'WordPress', + 'formId' => 'login', + ], + ]; + $dates = [ '2025-01-24', '2025-02-23' ]; + $args = [ + 'ids' => $ids, + 'dates' => $dates, + ]; + $prefix = 'wp_'; + $table_name = $prefix . Events::TABLE_NAME; + $where_clause = '((source = %s AND form_id = %s)) AND date_gmt BETWEEN %s AND %s AND status = %s'; + $where = "((source = 'WordPress' AND form_id = 'login')) AND date_gmt BETWEEN '2025-01-24 00:00:00' AND '2025-02-23 23:59:59' AND status = 'active'"; + $trashed_at_gmt = '2026-06-28 12:00:00'; + $sql = "UPDATE $table_name SET status = %s, trashed_at_gmt = %s WHERE $where"; + $prepared = 'prepared trash forms query'; + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [] ); + WP_Mock::passthruFunction( 'get_gmt_from_date' ); + WP_Mock::userFunction( 'wp_timezone' )->with()->andReturn( new DateTimeZone( 'UTC' ) ); + WP_Mock::userFunction( 'current_time' ) + ->with( 'mysql', true ) + ->andReturn( $trashed_at_gmt ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = Mockery::mock( 'WPDB' ); + $wpdb->prefix = $prefix; + $wpdb->shouldReceive( 'prepare' ) + ->with( $where_clause, 'WordPress', 'login', '2025-01-24 00:00:00', '2025-02-23 23:59:59', Events::STATUS_ACTIVE ) + ->andReturn( $where ); + $wpdb->shouldReceive( 'prepare' ) + ->with( $sql, Events::STATUS_TRASH, $trashed_at_gmt ) + ->andReturn( $prepared ); + $wpdb->shouldReceive( 'query' )->with( $prepared )->andReturn( count( $ids ) ); + + $subject = Mockery::mock( FormsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + self::assertTrue( $subject->trash_events( $args ) ); + } + + /** + * Test restore_events(). + * + * @return void + */ + public function test_restore_events(): void { + global $wpdb; + + $ids = [ + [ + 'source' => 'WordPress', + 'formId' => 'login', + ], + ]; + $dates = [ '2025-01-24', '2025-02-23' ]; + $args = [ + 'ids' => $ids, + 'dates' => $dates, + ]; + $prefix = 'wp_'; + $table_name = $prefix . Events::TABLE_NAME; + $where_clause = '((source = %s AND form_id = %s)) AND date_gmt BETWEEN %s AND %s AND status = %s'; + $where = "((source = 'WordPress' AND form_id = 'login')) AND date_gmt BETWEEN '2025-01-24 00:00:00' AND '2025-02-23 23:59:59' AND status = 'trash'"; + $sql = "UPDATE $table_name SET status = %s, trashed_at_gmt = NULL WHERE $where"; + $prepared = 'prepared restore forms query'; + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [] ); + WP_Mock::passthruFunction( 'get_gmt_from_date' ); + WP_Mock::userFunction( 'wp_timezone' )->with()->andReturn( new DateTimeZone( 'UTC' ) ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = Mockery::mock( 'WPDB' ); + $wpdb->prefix = $prefix; + $wpdb->shouldReceive( 'prepare' ) + ->with( $where_clause, 'WordPress', 'login', '2025-01-24 00:00:00', '2025-02-23 23:59:59', Events::STATUS_TRASH ) + ->andReturn( $where ); + $wpdb->shouldReceive( 'prepare' ) + ->with( $sql, Events::STATUS_ACTIVE ) + ->andReturn( $prepared ); + $wpdb->shouldReceive( 'query' )->with( $prepared )->andReturn( count( $ids ) ); + + $subject = Mockery::mock( FormsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + self::assertTrue( $subject->restore_events( $args ) ); + } + + /** + * Test restore_events() with empty args. + * + * @return void + */ + public function test_restore_events_with_empty_args(): void { + global $wpdb; + + $prefix = 'wp_'; + $args = [ 'dates' => [ '2025-01-24', '2025-02-23' ] ]; + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [] ); + WP_Mock::passthruFunction( 'get_gmt_from_date' ); + WP_Mock::userFunction( 'wp_timezone' )->with()->andReturn( new DateTimeZone( 'UTC' ) ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = Mockery::mock( 'WPDB' ); + $wpdb->prefix = $prefix; + + $subject = Mockery::mock( FormsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + self::assertFalse( $subject->restore_events( $args ) ); + } + /** + * Test delete_events() when the trash schema is not ready. + * + * @return void + */ + public function test_delete_events_when_schema_is_not_ready(): void { + $subject = Mockery::mock( FormsPage::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + WP_Mock::userFunction( 'get_option' ) + ->with( 'hcaptcha_versions', [] ) + ->andReturn( [ '5.0.0' => -1 ] ); + + self::assertFalse( + $subject->delete_events( + [ + 'ids' => [ + [ + 'source' => 'WordPress', + 'formId' => 'login', + ], + ], + ] + ) + ); + } } diff --git a/tests/php/unit/Settings/GeneralTest.php b/tests/php/unit/Settings/GeneralTest.php index 9d4d4b1cc..6776a162f 100644 --- a/tests/php/unit/Settings/GeneralTest.php +++ b/tests/php/unit/Settings/GeneralTest.php @@ -43,8 +43,7 @@ class GeneralTest extends HCaptchaTestCase { */ public function tearDown(): void { $_POST = []; - unset( $_GET[ OnboardingWizard::AUTO_SETUP_PARAM ] ); - unset( $_GET[ OnboardingWizard::NONCE_PARAM ] ); + unset( $_GET[ OnboardingWizard::AUTO_SETUP_PARAM ], $_GET[ OnboardingWizard::NONCE_PARAM ] ); parent::tearDown(); } @@ -238,7 +237,7 @@ static function ( $type, $name ) use ( $nonce ) { } /** - * Test maybe_handle_onboarding_auto_setup() when anti-spam provider is not configured. + * Test maybe_handle_onboarding_auto_setup() when the anti-spam provider is not configured. */ public function test_maybe_handle_onboarding_auto_setup_without_configured_antispam_provider(): void { $nonce = 'auto_setup_nonce'; @@ -406,6 +405,105 @@ public function test_auto_migration(): void { $method->setAccessible( false ); } + /** + * Test maybe_handle_onboarding_auto_setup() when not on the option screen. + */ + public function test_maybe_handle_onboarding_auto_setup_not_on_options_screen(): void { + $subject = Mockery::mock( General::class )->makePartial(); + + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'is_options_screen' )->once()->andReturn( false ); + $subject->shouldReceive( 'update_option' )->never(); + $subject->shouldReceive( 'should_enable_onboarding_antispam' )->never(); + $subject->shouldReceive( 'auto_migration' )->never(); + $subject->shouldReceive( 'redirect_after_onboarding_auto_setup' )->never(); + + $subject->maybe_handle_onboarding_auto_setup(); + } + + /** + * Test maybe_handle_onboarding_auto_setup() without an auto-setup request. + */ + public function test_maybe_handle_onboarding_auto_setup_without_auto_setup_request(): void { + FunctionMocker::replace( + '\HCaptcha\Helpers\Request::filter_input', + static function ( $type, $name ) { + if ( INPUT_GET === $type && OnboardingWizard::AUTO_SETUP_PARAM === $name ) { + return ''; + } + + return 'some value'; + } + ); + + $subject = Mockery::mock( General::class )->makePartial(); + + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'is_options_screen' )->once()->andReturn( true ); + $subject->shouldReceive( 'update_option' )->never(); + $subject->shouldReceive( 'should_enable_onboarding_antispam' )->never(); + $subject->shouldReceive( 'auto_migration' )->never(); + $subject->shouldReceive( 'redirect_after_onboarding_auto_setup' )->never(); + + $subject->maybe_handle_onboarding_auto_setup(); + } + + /** + * Test auto_migration() with no surfaces. + * + * @throws ReflectionException ReflectionException. + */ + public function test_auto_migration_with_no_surfaces(): void { + $scan_data = [ + 'already_enabled' => [], + 'results' => [ + 'not an array', + [ + 'surface' => 'wp_login', + 'confidence' => DetectionResult::CONFIDENCE_LOW, + 'is_migratable' => true, + 'hcaptcha_option_key' => 'wp_status', + 'hcaptcha_option_value' => 'login', + ], + ], + ]; + + $wizard = Mockery::mock( MigrationWizard::class ); + $wizard->shouldReceive( 'scan' )->once()->andReturn( $scan_data ); + $wizard->shouldReceive( 'apply' )->never(); + + $subject = Mockery::mock( General::class )->makePartial(); + + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'create_migration_wizard' )->once()->andReturn( $wizard ); + + $method = $this->set_method_accessibility( $subject, 'auto_migration' ); + $method->invoke( $subject ); + $method->setAccessible( false ); + } + + /** + * Test should_enable_onboarding_antispam(). + * + * @throws ReflectionException ReflectionException. + */ + public function test_should_enable_onboarding_antispam(): void { + $subject = Mockery::mock( General::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'get' )->with( 'antispam_provider' )->andReturn( '', 'some-provider' ); + + FunctionMocker::replace( + '\HCaptcha\AntiSpam\AntiSpam::get_configured_providers', + [ 'some-provider' ] + ); + + $method = $this->set_method_accessibility( $subject, 'should_enable_onboarding_antispam' ); + + self::assertFalse( $method->invoke( $subject ) ); + self::assertTrue( $method->invoke( $subject ) ); + + $method->setAccessible( false ); + } /** * Test init_form_fields() * diff --git a/tests/php/unit/Settings/IntegrationsTest.php b/tests/php/unit/Settings/IntegrationsTest.php index 2cfec6abd..8e9a21e30 100644 --- a/tests/php/unit/Settings/IntegrationsTest.php +++ b/tests/php/unit/Settings/IntegrationsTest.php @@ -16,17 +16,19 @@ use HCaptcha\CF7\Admin; use HCaptcha\CF7\CF7; use HCaptcha\CF7\ReallySimpleCaptcha; +use HCaptcha\Helpers\Utils; use HCaptcha\Main; +use HCaptcha\Settings\Integrations; use HCaptcha\Settings\PluginSettingsBase; use HCaptcha\Settings\Settings; -use KAGG\Settings\Abstracts\SettingsBase; -use HCaptcha\Settings\Integrations; use HCaptcha\Tests\Unit\HCaptchaTestCase; +use HCaptcha\Tests\Unit\Stubs\Settings\IntegrationsTestSubject; +use KAGG\Settings\Abstracts\SettingsBase; use Mockery; use ReflectionException; +use ReflectionMethod; use tad\FunctionMocker\FunctionMocker; use WP_Mock; -use HCaptcha\Helpers\Utils; /** * Class IntegrationsTest @@ -179,8 +181,7 @@ public function test_after_switch_theme_action_when_not_ajax(): void { public function test_filter_activate_plugins_no_dependant_companions(): void { $input = [ 'some/other.php' ]; $expected = $input; - - $main = Mockery::mock( Main::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); $main->shouldReceive( 'is_plugin_active' )->never(); WP_Mock::userFunction( 'hcaptcha' )->with()->andReturn( $main ); @@ -202,8 +203,7 @@ public function test_filter_activate_plugins_when_companion_already_active(): vo $companion_free = 'blocksy-companion/blocksy-companion.php'; $input = [ $companion_pro, 'some/other.php', $companion_free ]; $expected = [ 1 => 'some/other.php' ]; - - $main = Mockery::mock( Main::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); $main->shouldReceive( 'is_plugin_active' )->once()->with( $companion_pro )->andReturn( true ); WP_Mock::userFunction( 'hcaptcha' )->with()->andReturn( $main ); @@ -225,8 +225,7 @@ public function test_filter_activate_plugins_when_companion_installed_but_not_ac $companion_free = 'blocksy-companion/blocksy-companion.php'; $input = [ 'some/other.php', 'another.php', $companion_pro ]; $expected = [ 'some/other.php', 'another.php', $companion_free ]; - - $main = Mockery::mock( Main::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); $main->shouldReceive( 'is_plugin_active' )->once()->with( $companion_pro )->andReturn( false ); $main->shouldReceive( 'is_plugin_active' )->once()->with( $companion_free )->andReturn( false ); @@ -251,8 +250,7 @@ public function test_filter_activate_plugins_when_no_companion_installed_or_acti $companion_free = 'blocksy-companion/blocksy-companion.php'; $input = [ 'x.php', $companion_pro, $companion_free ]; $expected = $input; // Should return an original list. - - $main = Mockery::mock( Main::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); $main->shouldReceive( 'is_plugin_active' )->once()->with( $companion_pro )->andReturn( false ); $main->shouldReceive( 'is_plugin_active' )->once()->with( $companion_free )->andReturn( false ); @@ -338,7 +336,6 @@ public function test_setup_fields(): void { $form_fields['wp_status']['disabled'] = false; $form_fields['woocommerce_status']['disabled'] = false; - $main = Mockery::mock( Main::class )->makePartial(); $main->shouldAllowMockingProtectedMethods(); @@ -462,7 +459,7 @@ public function test_search_box(): void { + placeholder="Search integrations...">
    '; @@ -473,6 +470,61 @@ public function test_search_box(): void { self::assertSame( $expected, $output ); } + /** + * Test settings_page() when no antispam method is enabled. + */ + public function test_settings_page_adds_antispam_notice(): void { + global $wp_settings_sections; + + $page = 'hcaptcha-integrations'; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_settings_sections = [ + $page => [ + Integrations::SECTION_HEADER => [], + ], + ]; + + $settings = Mockery::mock( Settings::class )->makePartial(); + $settings->shouldReceive( 'is_on' )->with( 'honeypot' )->andReturn( false ); + $settings->shouldReceive( 'is_on' )->with( 'set_min_submit_time' )->andReturn( false ); + $main = Mockery::mock( Main::class )->makePartial(); + $main->shouldReceive( 'settings' )->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->andReturn( $main ); + WP_Mock::passthruFunction( 'esc_attr' ); + WP_Mock::passthruFunction( 'esc_url' ); + WP_Mock::userFunction( 'admin_url' )->with( 'options.php' )->andReturn( 'options.php' ); + WP_Mock::userFunction( 'do_settings_sections' )->with( $page )->once(); + WP_Mock::userFunction( 'settings_fields' )->with( 'hcaptcha' )->once(); + + FunctionMocker::replace( + 'constant', + static function ( string $name ): string { + return 'HCAPTCHA_URL' === $name + ? 'https://test.test/wp-content/plugins/hcaptcha-wordpress-plugin' + : ''; + } + ); + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'get_active_tab' )->with()->andReturn( $subject ); + $subject->shouldReceive( 'option_page' )->with()->andReturn( $page ); + $subject->shouldReceive( 'option_group' )->with()->andReturn( 'hcaptcha' ); + $subject->shouldReceive( 'section_title' )->with()->andReturn( 'integrations' ); + $subject->shouldReceive( 'get_savable_form_fields' )->with()->andReturn( [] ); + + ob_start(); + $subject->settings_page(); + ob_get_clean(); + + self::assertSame( + '

    Enable Honeypot or Time check in the Anti-Spam tab to use indicators.

    ', + $wp_settings_sections[ $page ][ Integrations::SECTION_HEADER ]['after_section'] + ); + } + /** * Test section_callback() * @@ -488,7 +540,6 @@ public function test_section_callback( string $id, string $expected ): void { $settings = Mockery::mock( Settings::class )->makePartial(); $settings->shouldReceive( 'is_on' )->with( 'honeypot' )->andReturn( false ); $settings->shouldReceive( 'is_on' )->with( 'set_min_submit_time' )->andReturn( false ); - $main = Mockery::mock( Main::class )->makePartial(); $main->shouldReceive( 'settings' )->andReturn( $settings ); @@ -501,6 +552,30 @@ public function test_section_callback( string $id, string $expected ): void { self::assertSame( $expected, ob_get_clean() ); } + /** + * Test section_callback() when antispam coverage is visible. + */ + public function test_section_callback_enabled_with_antispam_legend_visible(): void { + WP_Mock::passthruFunction( 'esc_attr' ); + WP_Mock::passthruFunction( 'wp_kses_post' ); + + $settings = Mockery::mock( Settings::class )->makePartial(); + $settings->shouldReceive( 'is_on' )->with( 'honeypot' )->andReturn( true ); + $settings->shouldReceive( 'is_on' )->with( 'show_antispam_coverage' )->andReturn( true ); + $main = Mockery::mock( Main::class )->makePartial(); + $main->shouldReceive( 'settings' )->andReturn( $settings ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->andReturn( $main ); + + $subject = Mockery::mock( Integrations::class )->makePartial()->shouldAllowMockingProtectedMethods(); + + ob_start(); + $subject->section_callback( [ 'id' => Integrations::SECTION_ENABLED ] ); + $output = ob_get_clean(); + + self::assertStringContainsString( 'style="display: block;"', $output ); + } + /** * Data provider for test_section_callback(). * @@ -573,7 +648,7 @@ public function test_admin_enqueue_scripts( bool $activated ): void { $_GET['suggest_activate'] = $cf7_status; $main = Mockery::mock( Main::class )->makePartial(); - $main->shouldReceive( 'is_plugin_active' )->once()->with( $cf7 )->andReturn( $activated ); + $main->shouldReceive( 'plugin_or_theme_active' )->once()->with( [ $cf7 ] )->andReturn( $activated ); $main->modules = [ 'Contact Form 7' => [ @@ -602,9 +677,7 @@ static function () use ( $default_theme ) { 'twentytwentyone' => $default_theme, ]; - $subject = Mockery::mock( Integrations::class )->makePartial(); - $subject->shouldAllowMockingProtectedMethods(); - $subject->shouldReceive( 'is_options_screen' )->with()->andReturn( true ); + $subject = IntegrationsTestSubject::make(); $this->set_protected_property( $subject, 'min_suffix', $min_suffix ); $this->set_protected_property( $subject, 'themes', $themes ); @@ -877,8 +950,7 @@ public function test_process_activate_plugins_success(): void { $form, ], ]; - - $main = Mockery::mock( Main::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); $main->modules = $modules; @@ -907,6 +979,79 @@ public function test_process_activate_plugins_success(): void { $subject->process_plugins( $activate, $plugins, $plugin_name ); } + /** + * Test process_plugins() with installation activation when success. + * + * @noinspection PhpConditionAlreadyCheckedInspection + * @throws ReflectionException ReflectionException. + */ + public function test_process_install_plugins_success(): void { + $activate = true; + $plugins = [ 'acf-extended-pro/acf-extended.php', 'acf-extended/acf-extended.php' ]; + $plugin_name = 'ACF Extended'; + $activation_stati = []; + $result = null; + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $this->set_protected_property( $subject, 'entity', 'plugin' ); + $this->set_protected_property( $subject, 'install', true ); + + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'activate_plugins' )->with( $plugins )->once()->andReturn( $result ); + $subject->shouldReceive( 'plugin_names_from_trees' ) + ->with()->once()->andReturn( [ $plugin_name ] ); + $subject->shouldReceive( 'get_activation_stati' )->with()->andReturn( $activation_stati ); + + WP_Mock::userFunction( 'is_wp_error' )->with( $result )->andReturn( false ); + WP_Mock::userFunction( 'wp_send_json_success' ) + ->with( + [ + 'message' => 'ACF Extended plugin is installed and activated.', + 'stati' => $activation_stati, + ] + ) + ->once(); + + $subject->process_plugins( $activate, $plugins, $plugin_name ); + } + + /** + * Test process_plugins() with installation activation when error. + * + * @noinspection PhpConditionAlreadyCheckedInspection + * @throws ReflectionException ReflectionException. + */ + public function test_process_install_plugins_error(): void { + $activate = true; + $plugins = [ 'acf-extended-pro/acf-extended.php', 'acf-extended/acf-extended.php' ]; + $plugin_name = 'ACF Extended'; + $activation_stati = []; + $error_message = 'some error message'; + $result = Mockery::mock(); + + $result->shouldReceive( 'get_error_message' )->andReturn( $error_message ); + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $this->set_protected_property( $subject, 'entity', 'plugin' ); + $this->set_protected_property( $subject, 'install', true ); + + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'activate_plugins' )->with( $plugins )->once()->andReturn( $result ); + $subject->shouldReceive( 'get_activation_stati' )->with()->andReturn( $activation_stati ); + + WP_Mock::userFunction( 'is_wp_error' )->with( $result )->andReturn( true ); + WP_Mock::userFunction( 'wp_send_json_error' ) + ->with( + [ + 'message' => "Error installing and activating $plugin_name plugin: $error_message.", + 'stati' => $activation_stati, + ] + ) + ->once(); + + $subject->process_plugins( $activate, $plugins, $plugin_name ); + } + /** * Test process_plugins() with activation when already activated. * @@ -1288,6 +1433,55 @@ static function ( $thing ) { $subject->$method( $theme ); } + /** + * Test process_theme() with installation activation when cannot be activated. + * + * @noinspection PhpConditionAlreadyCheckedInspection + * @throws ReflectionException ReflectionException. + */ + public function test_process_theme_when_install_activation_fails(): void { + $theme = 'Avada'; + $plugin_tree = [ 'some plugin tree' ]; + $stati = [ + 'wp_status' => true, + 'Avada' => true, + ]; + $themes = [ + 'twentytwentyone' => 'Twenty Twenty-One', + ]; + $default_theme = 'twentytwentyfour'; + $error_message = 'some error message'; + $error_arr = [ + 'message' => "Error installing and activating $theme theme: $error_message", + 'stati' => $stati, + 'themes' => $themes, + 'defaultTheme' => $default_theme, + ]; + $wp_error = Mockery::mock(); + + $wp_error->shouldReceive( 'get_error_message' )->andReturn( $error_message ); + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $method = 'process_theme'; + + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'get_default_theme' )->with()->once()->andReturn( $default_theme ); + $subject->shouldReceive( 'activate_plugins' ) + ->with( [ 'fusion-builder/fusion-builder.php', 'fusion-core/fusion-core.php' ], false )->once() + ->andReturn( true ); + $subject->shouldReceive( 'activate_theme' )->with( $theme )->once()->andReturn( $wp_error ); + $subject->shouldReceive( 'get_activation_stati' )->with()->once()->andReturn( $stati ); + $subject->shouldReceive( 'get_themes' )->with()->once()->andReturn( $themes ); + $this->set_protected_property( $subject, 'entity', 'theme' ); + $this->set_protected_property( $subject, 'install', true ); + $this->set_protected_property( $subject, 'plugin_trees', $plugin_tree ); + + WP_Mock::userFunction( 'is_wp_error' )->with( $wp_error )->andReturn( true ); + WP_Mock::userFunction( 'wp_send_json_error' )->with( $error_arr )->once(); + + $subject->$method( $theme ); + } + /** * Test activate_plugins(). * @@ -1441,8 +1635,7 @@ static function ( $thing ) { $wish_result = false === $wish_result ? $wp_error : $wish_result; $woo_result = false === $woo_result ? $wp_error : $woo_result; - - $main = Mockery::mock( Main::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); $main->shouldReceive( 'is_plugin_active' )->with( $wish_slug )->andReturn( false ); $main->shouldReceive( 'is_plugin_active' )->with( $woo_slug )->andReturn( false ); @@ -1485,6 +1678,22 @@ public function dp_test_activate_plugins_with_plugins_tree(): array { ]; } + /** + * Test activate_plugin() when the plugin is already active. + */ + public function test_activate_plugin_when_already_active(): void { + $plugin = 'example-plugin/example-plugin.php'; + $main = Mockery::mock( Main::class )->makePartial(); + $main->shouldReceive( 'is_plugin_active' )->with( $plugin )->once()->andReturn( true ); + + WP_Mock::userFunction( 'hcaptcha' )->with()->andReturn( $main ); + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + self::assertTrue( $subject->activate_plugin( $plugin ) ); + } + /** * Test maybe_activate_plugin(). * @@ -1578,6 +1787,34 @@ static function ( $filename ) use ( $wp_plugin_dir, $plugin ) { $this->assertNull( $subject->install_plugin( $plugin ) ); } + /** + * Test install_plugin() when the plugin is already installed. + * + * @return void + */ + public function test_install_plugin_when_already_installed(): void { + $wp_plugin_dir = '/path/to/plugins'; + $plugin = 'example-plugin/example-plugin.php'; + + FunctionMocker::replace( + 'constant', + static function ( $name ) use ( $wp_plugin_dir ) { + return 'WP_PLUGIN_DIR' === $name ? $wp_plugin_dir : ''; + } + ); + FunctionMocker::replace( + 'file_exists', + static function ( $filename ) use ( $wp_plugin_dir, $plugin ) { + return $wp_plugin_dir . '/' . $plugin === $filename; + } + ); + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + + $this->assertNull( $subject->install_plugin( $plugin ) ); + } + /** * Test install_plugin() when the plugin dir is empty. * @@ -1653,7 +1890,7 @@ static function ( $filename ) use ( $wp_plugin_dir, $plugin ) { } /** - * Test install_plugin() when API returns error. + * Test install_plugin() when the API returns an error. * * @return void */ @@ -1953,8 +2190,7 @@ public function test_plugin_names_from_tree(): void { 'result' => null, ]; $expected = [ 'Some Plugin Requiring Wishlist', 'WooCommerce Wishlists', 'WooCommerce' ]; - - $main = Mockery::mock( Main::class )->makePartial(); + $main = Mockery::mock( Main::class )->makePartial(); $main->modules = [ 'WooCommerce Register' => [ @@ -2171,7 +2407,7 @@ public function test_install_theme_when_not_allowed(): void { } /** - * Test install_theme() when API returns error. + * Test install_theme() when the API returns an error. * * @return void */ @@ -2442,6 +2678,59 @@ static function ( string $name ) use ( $plugin_dir ): bool { self::assertSame( [], $subject->$method( $slug ) ); } + /** + * Test get_plugin_data() checks whether the WP function exists. + * + * @throws ReflectionException ReflectionException. + */ + public function test_get_plugin_data_checks_function_exists(): void { + $slug = 'my-plugin/my-plugin.php'; + $plugin_file = 'C:/plugins/my-plugin/my-plugin.php'; + + FunctionMocker::replace( + 'function_exists', + static function ( string $function_name ): bool { + return 'get_plugin_data' === $function_name; + } + ); + FunctionMocker::replace( 'file_exists', false ); + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'get_plugin_file' )->with( $slug )->once()->andReturn( $plugin_file ); + + WP_Mock::userFunction( 'get_plugin_data' )->never(); + + $method = 'get_plugin_data'; + + self::assertSame( [], $subject->$method( $slug ) ); + } + + /** + * Test get_plugin_data() through the original class method. + * + * @throws ReflectionException ReflectionException. + */ + public function test_get_plugin_data_checks_function_exists_with_test_subject(): void { + $slug = 'my-plugin/my-plugin.php'; + $plugin_file = 'C:/plugins/my-plugin/my-plugin.php'; + + FunctionMocker::replace( + 'function_exists', + static function ( string $function_name ): bool { + return 'get_plugin_data' === $function_name; + } + ); + FunctionMocker::replace( 'file_exists', false ); + + $subject = IntegrationsTestSubject::make(); + $subject->set_plugin_file( $plugin_file ); + + WP_Mock::userFunction( 'get_plugin_data' )->never(); + + self::assertSame( [], $subject->call_get_plugin_data( $slug ) ); + } + /** * Test get_plugin_data() when the plugin is installed: * delegates to WP get_plugin_data with a correct path and flags. @@ -2483,6 +2772,146 @@ public function test_get_plugin_data_when_installed_calls_wp_and_uses_path_and_f self::assertSame( $expected, $subject->$method( $slug, $markup, $translate ) ); } + /** + * Test setup_paypal_payments_dependency_notice() without PayPal buttons. + * + * @throws ReflectionException ReflectionException. + */ + public function test_setup_paypal_payments_dependency_notice_returns_without_button(): void { + $form_fields = [ + 'paypal_payments_status' => [], + ]; + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'get' )->with( 'paypal_payments_status' )->andReturn( [] ); + $subject->shouldReceive( 'get' )->with( 'woocommerce_status' )->never(); + $this->set_protected_property( $subject, 'form_fields', $form_fields ); + + $method = new ReflectionMethod( Integrations::class, 'setup_paypal_payments_dependency_notice' ); + $method->setAccessible( true ); + $method->invoke( $subject ); + $method->setAccessible( false ); + + $form_fields = $this->get_protected_property( $subject, 'form_fields' ); + + self::assertArrayNotHasKey( 'supplemental', $form_fields['paypal_payments_status'] ); + } + + /** + * Test setup_paypal_payments_dependency_notice() without a checkout option id. + * + * @throws ReflectionException ReflectionException. + */ + public function test_setup_paypal_payments_dependency_notice_returns_without_checkout_id(): void { + $form_fields = [ + 'paypal_payments_status' => [], + 'woocommerce_status' => [ + 'options' => [ + 'login' => 'Login Form', + ], + ], + ]; + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'get' )->with( 'paypal_payments_status' )->andReturn( [ 'button' ] ); + $subject->shouldReceive( 'get' )->with( 'woocommerce_status' )->andReturn( [] ); + $this->set_protected_property( $subject, 'form_fields', $form_fields ); + + $method = new ReflectionMethod( Integrations::class, 'setup_paypal_payments_dependency_notice' ); + $method->setAccessible( true ); + $method->invoke( $subject ); + $method->setAccessible( false ); + + $form_fields = $this->get_protected_property( $subject, 'form_fields' ); + + self::assertArrayNotHasKey( 'supplemental', $form_fields['paypal_payments_status'] ); + } + + /** + * Test setup_paypal_payments_dependency_notice(). + * + * @throws ReflectionException ReflectionException. + * @noinspection HtmlUnknownAnchorTarget + */ + public function test_setup_paypal_payments_dependency_notice(): void { + $form_fields = [ + 'paypal_payments_status' => [], + 'woocommerce_status' => [ + 'options' => [ + 'login' => 'Login Form', + 'checkout' => 'Checkout Form', + ], + ], + ]; + $expected = 'Checkout PayPal buttons use WooCommerce Checkout hCaptcha. Enable ' . + 'WooCommerce Checkout to protect them.'; + + WP_Mock::passthruFunction( 'esc_attr' ); + WP_Mock::passthruFunction( 'esc_html__' ); + WP_Mock::passthruFunction( '__' ); + + $subject = Mockery::mock( Integrations::class )->makePartial(); + $subject->shouldAllowMockingProtectedMethods(); + $subject->shouldReceive( 'get' )->with( 'paypal_payments_status' )->andReturn( [ 'button' ] ); + $subject->shouldReceive( 'get' )->with( 'woocommerce_status' )->andReturn( [] ); + $this->set_protected_property( $subject, 'form_fields', $form_fields ); + + $method = new ReflectionMethod( Integrations::class, 'setup_paypal_payments_dependency_notice' ); + $method->setAccessible( true ); + $method->invoke( $subject ); + $method->setAccessible( false ); + + $form_fields = $this->get_protected_property( $subject, 'form_fields' ); + + self::assertSame( $expected, $form_fields['paypal_payments_status']['supplemental'] ); + } + + /** + * Test get_checkbox_option_id(). + * + * @throws ReflectionException ReflectionException. + */ + public function test_get_checkbox_option_id(): void { + $form_fields = [ + 'paypal_payments_status' => [ + 'disabled' => false, + 'section' => Integrations::SECTION_DISABLED, + ], + 'woocommerce_status' => [ + 'disabled' => false, + 'section' => Integrations::SECTION_DISABLED, + 'options' => [ + 'login' => 'Login Form', + 'checkout' => 'Checkout Form', + ], + ], + ]; + + $subject = IntegrationsTestSubject::make(); + $subject->set_test_settings( + [ + 'paypal_payments_status' => [ 'button' ], + 'woocommerce_status' => [], + ] + ); + $this->set_protected_property( $subject, 'form_fields', $form_fields ); + + WP_Mock::passthruFunction( 'esc_attr' ); + WP_Mock::passthruFunction( 'esc_html__' ); + WP_Mock::passthruFunction( '__' ); + + $subject->call_setup_field_data( [ 'woocommerce_status', 'paypal_payments_status' ] ); + + $form_fields = $this->get_protected_property( $subject, 'form_fields' ); + + self::assertStringContainsString( + '#woocommerce_status_2', + $form_fields['paypal_payments_status']['supplemental'] + ); + } + /** * Test prepare_antispam_data() with native and hcaptcha entries where hcaptcha is enabled. */ @@ -2501,7 +2930,6 @@ public function test_prepare_antispam_data_with_native_and_hcaptcha_enabled(): v // Mock hcaptcha()->settings()->is to return true for the hcaptcha form. $settings = Mockery::mock( Settings::class )->makePartial(); $settings->shouldReceive( 'is' )->with( $status, 'advanced_form' )->andReturn( true ); - $main = Mockery::mock( Main::class )->makePartial(); $main->shouldReceive( 'settings' )->andReturn( $settings ); WP_Mock::userFunction( 'hcaptcha' )->andReturn( $main ); @@ -2537,7 +2965,6 @@ public function test_prepare_antispam_data_hcaptcha_disabled(): void { // Reuse the cached protected forms from the first test to avoid static cache conflicts. $settings = Mockery::mock( Settings::class )->makePartial(); $settings->shouldReceive( 'is' )->with( $status, 'advanced_form' )->andReturn( false ); - $main = Mockery::mock( Main::class )->makePartial(); $main->shouldReceive( 'settings' )->andReturn( $settings ); WP_Mock::userFunction( 'hcaptcha' )->andReturn( $main ); diff --git a/tests/php/unit/Settings/ListPageBaseTest.php b/tests/php/unit/Settings/ListPageBaseTest.php index 231db82d0..e509ed52d 100644 --- a/tests/php/unit/Settings/ListPageBaseTest.php +++ b/tests/php/unit/Settings/ListPageBaseTest.php @@ -125,6 +125,7 @@ public function test_date_picker_display_when_not_allowed(): void { * * @return void * @dataProvider dp_test_bulk_action + * @noinspection JsonEncodingApiUsageInspection */ public function test_bulk_action( ?string $date ): void { $ids = [ 1, 2, 3 ]; @@ -486,20 +487,57 @@ static function ( $datetime, $timezone ) { self::assertSame( $expected[3], $actual[3] ); } + /** + * Test get_timespan_dates() when date modification throws. + * + * @return void + */ + public function test_get_timespan_dates_when_modify_throws(): void { + $expected_start = new DateTimeImmutable( '2024-05-27 00:00:00' ); + $expected_end = new DateTimeImmutable( '2024-05-27 23:59:59' ); + $date = Mockery::mock(); + $subject = Mockery::mock( ListPageBase::class )->makePartial(); + + $date->shouldReceive( 'modify' )->with( '-30 day' )->andThrow( new Exception( 'bad date' ) ); + $date->shouldReceive( 'setTime' )->with( 0, 0 )->andReturn( $expected_start ); + $date->shouldReceive( 'setTime' )->with( 23, 59, 59 )->andReturn( $expected_end ); + $subject->shouldAllowMockingProtectedMethods(); + + FunctionMocker::replace( + 'date_create_immutable', + static function () use ( $date ) { + return $date; + } + ); + + WP_Mock::userFunction( 'wp_timezone' )->with()->andReturn( new DateTimeZone( 'UTC' ) ); + + $actual = $subject->get_timespan_dates( '30' ); + + self::assertSame( $expected_start->getTimestamp(), $actual[0]->getTimestamp() ); + self::assertSame( $expected_end->getTimestamp(), $actual[1]->getTimestamp() ); + self::assertSame( '30', $actual[2] ); + self::assertSame( 'Last 30 days', $actual[3] ); + } /** * Data provider for test_get_timespan_dates(). * * @return array */ public function dp_test_get_timespan_dates(): array { - $start_date = date_create_immutable( '2024-04-27 00:00:00' ); - $end_date = date_create_immutable( '2024-05-27 23:59:59' ); + $start_date = date_create_immutable( '2024-04-27 00:00:00' ); + $end_date = date_create_immutable( '2024-05-27 23:59:59' ); + $exception_start = date_create_immutable( '2024-05-27 00:00:00' ); return [ [ '30', [ $start_date, $end_date, '30', 'Last 30 days' ], ], + [ + '2.2250738585072011e-308', + [ $exception_start, $end_date, 'custom', 'Custom' ], + ], [ 'custom', [ '', '', 'custom', 'Custom' ], diff --git a/tests/php/unit/Settings/SettingsTest.php b/tests/php/unit/Settings/SettingsTest.php index 2edefa7c6..1f4fbd6e2 100644 --- a/tests/php/unit/Settings/SettingsTest.php +++ b/tests/php/unit/Settings/SettingsTest.php @@ -7,7 +7,6 @@ // phpcs:disable Generic.Commenting.DocComment.MissingShort /** @noinspection PhpUndefinedMethodInspection */ -/** @noinspection PhpArrayShapeAttributeCanBeAddedInspection */ /** @noinspection PhpUndefinedClassInspection */ /** @noinspection PhpUndefinedNamespaceInspection */ // phpcs:enable Generic.Commenting.DocComment.MissingShort @@ -301,7 +300,7 @@ public function test_get_custom_theme_background(): void { $subject = Mockery::mock( Settings::class )->makePartial(); $subject->shouldReceive( 'is_on' )->with( 'custom_themes' )->andReturnUsing( - static function ( $key ) use ( &$custom_themes ) { + static function () use ( &$custom_themes ) { return $custom_themes; } ); @@ -343,6 +342,46 @@ static function () use ( &$config_params ) { self::assertSame( $bg, $subject->get_custom_theme_background() ); } + /** + * Test get_raw_settings(). + * + * @throws ReflectionException ReflectionException. + */ + public function test_get_raw_settings(): void { + $raw_settings = [ 'some key' => 'some value' ]; + $general = Mockery::mock( General::class )->makePartial(); + $subject = Mockery::mock( Settings::class )->makePartial(); + + self::assertNull( $subject->get_raw_settings() ); + + $general->shouldReceive( 'get_raw_settings' )->with()->once()->andReturn( $raw_settings ); + $this->set_protected_property( $subject, 'tabs', [ $general ] ); + + self::assertSame( $raw_settings, $subject->get_raw_settings() ); + } + + /** + * Test update(). + * + * @return void + * @throws ReflectionException ReflectionException. + */ + public function test_update(): void { + $key = 'some key'; + $value = 'some value'; + $general = Mockery::mock( General::class )->makePartial(); + $subject = Mockery::mock( Settings::class )->makePartial(); + + $subject->shouldReceive( 'set' )->with( $key, $value )->andReturn( false, true, true ); + + self::assertFalse( $subject->update( $key, $value ) ); + self::assertFalse( $subject->update( $key, $value ) ); + + $general->shouldReceive( 'update_option' )->with( $key, $value )->once(); + $this->set_protected_property( $subject, 'tabs', [ $general ] ); + + self::assertTrue( $subject->update( $key, $value ) ); + } /** * Test get(). * diff --git a/tests/php/unit/Settings/SystemInfoTest.php b/tests/php/unit/Settings/SystemInfoTest.php index 638b7b05f..1d5862022 100644 --- a/tests/php/unit/Settings/SystemInfoTest.php +++ b/tests/php/unit/Settings/SystemInfoTest.php @@ -197,9 +197,11 @@ public function test_section_callback(): void { public function test_get_system_info(): void { $plugin_version = '1.0.0'; $migrations = [ - '2.0.0' => 1662205373, - '3.6.0' => 1703257543, + '2.0.0' => 0, + '3.6.0' => 1662205373, '3.10.1' => 1711985976, + '4.0.0' => Migrations::STARTED, + '5.0.0' => Migrations::FAILED, ]; $date_format = 'd.m.Y'; $time_format = 'H:i'; @@ -318,9 +320,11 @@ public function test_get_system_info(): void { Failed login attempts interval, min: Delay showing hCaptcha, ms: Migrations: - 2.0.0: 03.09.2022 11:42 - 3.6.0: 22.12.2023 15:05 + 2.0.0: Not required + 3.6.0: 03.09.2022 11:42 3.10.1: 01.04.2024 15:39 + 4.0.0: Started + 5.0.0: Failed --- Integrations header info --- diff --git a/tests/php/unit/Stubs/Settings/IntegrationsTestSubject.php b/tests/php/unit/Stubs/Settings/IntegrationsTestSubject.php new file mode 100644 index 000000000..bbc489892 --- /dev/null +++ b/tests/php/unit/Stubs/Settings/IntegrationsTestSubject.php @@ -0,0 +1,121 @@ +newInstanceWithoutConstructor(); + } + /** + * Test settings values. + * + * @var array + */ + private array $test_settings = []; + + /** + * Plugin file path. + * + * @var string + */ + private string $plugin_file = ''; + + /** + * Whether the current screen is an option screen. + * + * @param string|array $ids Screen ids. + * + * @return bool + */ + public function is_options_screen( $ids = 'options' ): bool { + return true; + } + + /** + * Set test settings values. + * + * @param array $test_settings Test settings values. + * + * @return void + */ + public function set_test_settings( array $test_settings ): void { + $this->test_settings = $test_settings; + } + + /** + * Set the plugin file path. + * + * @param string $plugin_file Plugin file path. + * + * @return void + */ + public function set_plugin_file( string $plugin_file ): void { + $this->plugin_file = $plugin_file; + } + + /** + * Get a plugin option. + * + * @param string $key Setting name. + * @param mixed $empty_value Empty value for this setting. + * + * @return mixed + */ + public function get( string $key, $empty_value = null ) { + return $this->test_settings[ $key ] ?? $empty_value ?? ''; + } + + /** + * Call get_plugin_data(). + * + * @param string $plugin Plugin slug. + * @param bool $markup Optional. If the returned data should have HTML markup applied. + * @param bool $translate Optional. If the returned data should be translated. + * + * @return array + */ + public function call_get_plugin_data( string $plugin, bool $markup = true, bool $translate = true ): array { + return $this->get_plugin_data( $plugin, $markup, $translate ); + } + + /** + * Call setup_field_data(). + * + * @param array $installed Installed entities. + * + * @return void + */ + public function call_setup_field_data( array $installed ): void { + $this->setup_field_data( $installed ); + } + + /** + * Get a plugin file from the plugin slug. + * + * @param string $plugin Plugin slug. + * + * @return string + */ + protected function get_plugin_file( string $plugin ): string { + return $this->plugin_file ?: parent::get_plugin_file( $plugin ); + } +} diff --git a/tests/php/unit/_bootstrap.php b/tests/php/unit/_bootstrap.php index a45e4f298..bb9bba257 100644 --- a/tests/php/unit/_bootstrap.php +++ b/tests/php/unit/_bootstrap.php @@ -87,6 +87,7 @@ 'header_remove', 'http_response_code', 'ini_get', + 'is_readable', 'uniqid', ], ] diff --git a/yarn.lock b/yarn.lock index 49c641682..f6849a166 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,16 +5,6 @@ __metadata: version: 10 cacheKey: 10 -"@ampproject/remapping@npm:^2.2.0": - version: 2.3.0 - resolution: "@ampproject/remapping@npm:2.3.0" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10/f3451525379c68a73eb0a1e65247fbf28c0cccd126d93af21c75fceff77773d43c0d4a2d51978fb131aff25b5f2cb41a9fe48cc296e61ae65e679c4f6918b0ab - languageName: node - linkType: hard - "@asamuzakjp/css-color@npm:^3.2.0": version: 3.2.0 resolution: "@asamuzakjp/css-color@npm:3.2.0" @@ -28,73 +18,50 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.25.7, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/code-frame@npm:7.29.0" +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" dependencies: - "@babel/helper-validator-identifier": "npm:^7.28.5" + "@babel/helper-validator-identifier": "npm:^7.29.7" js-tokens: "npm:^4.0.0" picocolors: "npm:^1.1.1" - checksum: 10/199e15ff89007dd30675655eec52481cb245c9fdf4f81e4dc1f866603b0217b57aff25f5ffa0a95bbc8e31eb861695330cd7869ad52cc211aa63016320ef72c5 + checksum: 10/84da552e51a55795a50b3589116edb2f9e368a647d266380683775f18effd9acd4521b0246bebd0b049a7f32af1f87b1e8475d3bcb665f876bd04ade8da99697 languageName: node linkType: hard -"@babel/compat-data@npm:^7.25.7, @babel/compat-data@npm:^7.28.6, @babel/compat-data@npm:^7.29.3": - version: 7.29.3 - resolution: "@babel/compat-data@npm:7.29.3" - checksum: 10/3c29661756a7c1cbc5248a7bdc657c0cb49f350e3157040c20486759f1f50a08a0b385fd7d813df50b96cd6fad5896d30ba6abab7602641bd1410ed346c1812f +"@babel/compat-data@npm:^7.28.6, @babel/compat-data@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/compat-data@npm:7.29.7" + checksum: 10/ad2272714087f68970977f6e2b53597a8503fc9c3028c4a91686474bd77a707dd00903cdde4b73788972016d1bad4dc3fa4e5ff38e1ed8f1c3bde1095352973a languageName: node linkType: hard -"@babel/core@npm:7.25.7": - version: 7.25.7 - resolution: "@babel/core@npm:7.25.7" +"@babel/core@npm:^7.23.9, @babel/core@npm:^7.24.4, @babel/core@npm:^7.25.7, @babel/core@npm:^7.27.4, @babel/core@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/core@npm:7.29.7" dependencies: - "@ampproject/remapping": "npm:^2.2.0" - "@babel/code-frame": "npm:^7.25.7" - "@babel/generator": "npm:^7.25.7" - "@babel/helper-compilation-targets": "npm:^7.25.7" - "@babel/helper-module-transforms": "npm:^7.25.7" - "@babel/helpers": "npm:^7.25.7" - "@babel/parser": "npm:^7.25.7" - "@babel/template": "npm:^7.25.7" - "@babel/traverse": "npm:^7.25.7" - "@babel/types": "npm:^7.25.7" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10/f5fb7fb1e3ce357485cb33fe7984051a2d416472370b33144ae809df86a4663192b58cf0d828d40674d30f485790f3dd5aaf72eb659487673a4dc4be47cb3575 - languageName: node - linkType: hard - -"@babel/core@npm:^7.23.9, @babel/core@npm:^7.24.4, @babel/core@npm:^7.27.4, @babel/core@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/core@npm:7.29.0" - dependencies: - "@babel/code-frame": "npm:^7.29.0" - "@babel/generator": "npm:^7.29.0" - "@babel/helper-compilation-targets": "npm:^7.28.6" - "@babel/helper-module-transforms": "npm:^7.28.6" - "@babel/helpers": "npm:^7.28.6" - "@babel/parser": "npm:^7.29.0" - "@babel/template": "npm:^7.28.6" - "@babel/traverse": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.7" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helpers": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" "@jridgewell/remapping": "npm:^2.3.5" convert-source-map: "npm:^2.0.0" debug: "npm:^4.1.0" gensync: "npm:^1.0.0-beta.2" json5: "npm:^2.2.3" semver: "npm:^6.3.1" - checksum: 10/25f4e91688cdfbaf1365831f4f245b436cdaabe63d59389b75752013b8d61819ee4257101b52fc328b0546159fd7d0e74457ed7cf12c365fea54be4fb0a40229 + checksum: 10/38e71cf81db790b0bb2a3a0c8140c2b1c87576b61dc6be676de4fab8c3be871af590a739e8c489fe8e8f9a8e5899fa11e35e59e9e09d40b259c6a675f2f98928 languageName: node linkType: hard "@babel/eslint-parser@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/eslint-parser@npm:7.28.6" + version: 7.29.7 + resolution: "@babel/eslint-parser@npm:7.29.7" dependencies: "@nicolo-ribaudo/eslint-scope-5-internals": "npm:5.1.1-v1" eslint-visitor-keys: "npm:^2.1.0" @@ -102,76 +69,76 @@ __metadata: peerDependencies: "@babel/core": ^7.11.0 eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 - checksum: 10/15c0c9c78abcc5f267a34bab95437c37dfc468e3ac5e11094ed26bebd63c7a5b056fa47c005ba74eb9fbed6c79e37f90cbe2a24ed09425921275391fe9a5bbe7 + checksum: 10/fb12b127ba7231b07eb4a809fd7cf07f9df6f1abe5f9a0ce8be5edfce07c5a9f6f5745cd608af19b037961e4e8d5e30f844c7fa5f7b3d6d24f017f2194156a50 languageName: node linkType: hard -"@babel/generator@npm:^7.25.7, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.0": - version: 7.29.1 - resolution: "@babel/generator@npm:7.29.1" +"@babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/generator@npm:7.29.7" dependencies: - "@babel/parser": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" "@jridgewell/gen-mapping": "npm:^0.3.12" "@jridgewell/trace-mapping": "npm:^0.3.28" jsesc: "npm:^3.0.2" - checksum: 10/61fe4ddd6e817aa312a14963ccdbb5c9a8c57e8b97b98d19a8a99ccab2215fda1a5f52bc8dd8d2e3c064497ddeb3ab8ceb55c76fa0f58f8169c34679d2256fe0 + checksum: 10/60fb0432ebeab791b2d68e5fc49da6f8e8b68bcc4751211ccf08ac0101e9dcaddefd0cbbbd488afb1c1517515c7c3e76f63d9b05d06deaeb008afd499488db9c languageName: node linkType: hard -"@babel/helper-annotate-as-pure@npm:^7.25.7, @babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": - version: 7.27.3 - resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" +"@babel/helper-annotate-as-pure@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-annotate-as-pure@npm:7.29.7" dependencies: - "@babel/types": "npm:^7.27.3" - checksum: 10/63863a5c936ef82b546ca289c9d1b18fabfc24da5c4ee382830b124e2e79b68d626207febc8d4bffc720f50b2ee65691d7d12cc0308679dee2cd6bdc926b7190 + "@babel/types": "npm:^7.29.7" + checksum: 10/acd9e128de634a5144b5d622357d018fa616de45f64c74e42007c048dd15d0a0be213f4d5a2bf02307bdaddf053791b87900a99d183de828c08dc3b556329009 languageName: node linkType: hard -"@babel/helper-compilation-targets@npm:^7.25.7, @babel/helper-compilation-targets@npm:^7.27.1, @babel/helper-compilation-targets@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-compilation-targets@npm:7.28.6" +"@babel/helper-compilation-targets@npm:^7.28.6, @babel/helper-compilation-targets@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-compilation-targets@npm:7.29.7" dependencies: - "@babel/compat-data": "npm:^7.28.6" - "@babel/helper-validator-option": "npm:^7.27.1" + "@babel/compat-data": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" browserslist: "npm:^4.24.0" lru-cache: "npm:^5.1.1" semver: "npm:^6.3.1" - checksum: 10/f512a5aeee4dfc6ea8807f521d085fdca8d66a7d068a6dd5e5b37da10a6081d648c0bbf66791a081e4e8e6556758da44831b331540965dfbf4f5275f3d0a8788 + checksum: 10/af9ed4299ad5cfbe48432a964f37cbbfc200bbeb0f8ba9cbc86448503fa929382d5161d32096274752230c9feb919c9ef595559498833da656fc6a8e24a62383 languageName: node linkType: hard -"@babel/helper-create-class-features-plugin@npm:^7.28.6": - version: 7.29.3 - resolution: "@babel/helper-create-class-features-plugin@npm:7.29.3" +"@babel/helper-create-class-features-plugin@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-create-class-features-plugin@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.27.3" - "@babel/helper-member-expression-to-functions": "npm:^7.28.5" - "@babel/helper-optimise-call-expression": "npm:^7.27.1" - "@babel/helper-replace-supers": "npm:^7.28.6" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" - "@babel/traverse": "npm:^7.29.0" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-member-expression-to-functions": "npm:^7.29.7" + "@babel/helper-optimise-call-expression": "npm:^7.29.7" + "@babel/helper-replace-supers": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/3f72aaa26d2207bb87cbd340e1b52f45c5272008651517918192a6bd4ebafb2588c9432b231b64b55da07db953056d8abfacf490f80229ed6bb1726656bf8b7e + checksum: 10/74f871e5389beca9fb52670f2bd83abdd6dc7b7a10f34679ffab5eabf91077dccaabf55438b9f3c897258fb81fbb80bfbf469b836a404abb7e64b4d7c141a8da languageName: node linkType: hard -"@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.27.1, @babel/helper-create-regexp-features-plugin@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/helper-create-regexp-features-plugin@npm:7.28.5" +"@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-create-regexp-features-plugin@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.27.3" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" regexpu-core: "npm:^6.3.1" semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/d8791350fe0479af0909aa5efb6dfd3bacda743c7c3f8fa1b0bb18fe014c206505834102ee24382df1cfe5a83b4e4083220e97f420a48b2cec15bb1ad6c7c9d3 + checksum: 10/bf79ffc671d824d00b43a018555cb9fb3f2bc8be8d8ed8c901131e4cd072cc83e610a2cbb580f5f84b60d70bf4c7a7a8c5a629b7b325e1a27dca86d96d2668e0 languageName: node linkType: hard -"@babel/helper-define-polyfill-provider@npm:^0.6.2, @babel/helper-define-polyfill-provider@npm:^0.6.8": +"@babel/helper-define-polyfill-provider@npm:^0.6.5, @babel/helper-define-polyfill-provider@npm:^0.6.8": version: 0.6.8 resolution: "@babel/helper-define-polyfill-provider@npm:0.6.8" dependencies: @@ -186,219 +153,219 @@ __metadata: languageName: node linkType: hard -"@babel/helper-globals@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/helper-globals@npm:7.28.0" - checksum: 10/91445f7edfde9b65dcac47f4f858f68dc1661bf73332060ab67ad7cc7b313421099a2bfc4bda30c3db3842cfa1e86fffbb0d7b2c5205a177d91b22c8d7d9cb47 +"@babel/helper-globals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-globals@npm:7.29.7" + checksum: 10/e53203e87ae24a45f59639edea0c429bc3c63c6d74f1862fe60a35032d89478e7511d2f34855da0fcb65782668d72e59e93d1de5bc00121ba9bc1aa38f1f0ad3 languageName: node linkType: hard -"@babel/helper-member-expression-to-functions@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/helper-member-expression-to-functions@npm:7.28.5" +"@babel/helper-member-expression-to-functions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-member-expression-to-functions@npm:7.29.7" dependencies: - "@babel/traverse": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" - checksum: 10/05e0857cf7913f03d88ca62952d3888693c21a4f4d7cfc141c630983f71fc0a64393e05cecceb7701dfe98298f7cc38fcb735d892e3c8c6f56f112c85ee1b154 + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10/bb8dc59a65b4404260e0b7ff70f491de5a1607876f61736d26605ab3cba5b368827b0551acd3458212f5cfa99cbcd48bb66a96497b0fe00af09a6a2cbea4276b languageName: node linkType: hard -"@babel/helper-module-imports@npm:^7.25.7, @babel/helper-module-imports@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-module-imports@npm:7.28.6" +"@babel/helper-module-imports@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-imports@npm:7.29.7" dependencies: - "@babel/traverse": "npm:^7.28.6" - "@babel/types": "npm:^7.28.6" - checksum: 10/64b1380d74425566a3c288074d7ce4dea56d775d2d3325a3d4a6df1dca702916c1d268133b6f385de9ba5b822b3c6e2af5d3b11ac88e5453d5698d77264f0ec0 + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10/28ec6f7efd99588d6eebfb25c9f1ccc34cb0cdb0839c4c0f08b3ec0105ccaefbe7e8b4f651f3f55a4f5c4fcb1d979bd32a9b8ee23e3e62163ea22aaa7ee0dfa1 languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.25.7, @babel/helper-module-transforms@npm:^7.27.1, @babel/helper-module-transforms@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-module-transforms@npm:7.28.6" +"@babel/helper-module-transforms@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-transforms@npm:7.29.7" dependencies: - "@babel/helper-module-imports": "npm:^7.28.6" - "@babel/helper-validator-identifier": "npm:^7.28.5" - "@babel/traverse": "npm:^7.28.6" + "@babel/helper-module-imports": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/2e421c7db743249819ee51e83054952709dc2e197c7d5d415b4bdddc718580195704bfcdf38544b3f674efc2eccd4d29a65d38678fc827ed3934a7690984cd8b + checksum: 10/33251b1fb44d726194a974a0078b1269511d130a2609357ff829b479e9e4dca96ecd5384c534a477095f665ffb01503d3e680699c2002e5b62e6ca1a272f1892 languageName: node linkType: hard -"@babel/helper-optimise-call-expression@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-optimise-call-expression@npm:7.27.1" +"@babel/helper-optimise-call-expression@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-optimise-call-expression@npm:7.29.7" dependencies: - "@babel/types": "npm:^7.27.1" - checksum: 10/0fb7ee824a384529d6b74f8a58279f9b56bfe3cce332168067dddeab2552d8eeb56dc8eaf86c04a3a09166a316cb92dfc79c4c623cd034ad4c563952c98b464f + "@babel/types": "npm:^7.29.7" + checksum: 10/6b477e01b403fd48349336cb1d94722bff4fa54af2841b5fa950c557b796f4ecc14724052252ed1362ccfc23d1c09c54dc03e182fea59d3dc5bd69f8c626ba25 languageName: node linkType: hard -"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.25.7, @babel/helper-plugin-utils@npm:^7.25.9, @babel/helper-plugin-utils@npm:^7.27.1, @babel/helper-plugin-utils@npm:^7.28.6, @babel/helper-plugin-utils@npm:^7.8.0, @babel/helper-plugin-utils@npm:^7.8.3": - version: 7.28.6 - resolution: "@babel/helper-plugin-utils@npm:7.28.6" - checksum: 10/21c853bbc13dbdddf03309c9a0477270124ad48989e1ad6524b83e83a77524b333f92edd2caae645c5a7ecf264ec6d04a9ebe15aeb54c7f33c037b71ec521e4a +"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.28.6, @babel/helper-plugin-utils@npm:^7.29.7, @babel/helper-plugin-utils@npm:^7.8.0": + version: 7.29.7 + resolution: "@babel/helper-plugin-utils@npm:7.29.7" + checksum: 10/6d16929fe5c792bbc8e4d67e18d7c1be69d2f18992deaa3d94dc26541fec662e83cbeeaf7553c6867d068eb7aed4e0d5e3e137c1dd4d5bcfa286f8d772f1f457 languageName: node linkType: hard -"@babel/helper-remap-async-to-generator@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-remap-async-to-generator@npm:7.27.1" +"@babel/helper-remap-async-to-generator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-remap-async-to-generator@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.27.1" - "@babel/helper-wrap-function": "npm:^7.27.1" - "@babel/traverse": "npm:^7.27.1" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-wrap-function": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/0747397ba013f87dbf575454a76c18210d61c7c9af0f697546b4bcac670b54ddc156330234407b397f0c948738c304c228e0223039bc45eab4fbf46966a5e8cc + checksum: 10/98338ad6e34ebb4be2dc23f8d9199d28d6d8ac6a2ce8b90fe9efdf3595b39748321528d9f2540ec0586a6e45f7c84f5f623fbf980c5efa7fa9ba7ce837ea4b20 languageName: node linkType: hard -"@babel/helper-replace-supers@npm:^7.27.1, @babel/helper-replace-supers@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-replace-supers@npm:7.28.6" +"@babel/helper-replace-supers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-replace-supers@npm:7.29.7" dependencies: - "@babel/helper-member-expression-to-functions": "npm:^7.28.5" - "@babel/helper-optimise-call-expression": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.6" + "@babel/helper-member-expression-to-functions": "npm:^7.29.7" + "@babel/helper-optimise-call-expression": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/ad2724713a4d983208f509e9607e8f950855f11bd97518a700057eb8bec69d687a8f90dc2da0c3c47281d2e3b79cf1d14ecf1fe3e1ee0a8e90b61aee6759c9a7 + checksum: 10/4aa7b48a6078db99bba24b67f63f97cd08ad9b3c476dcca196c6421dc2080f3566d683fba64c772e2f9597603d42ad4ac2ce9ccf0559643823c540f08cf0efa7 languageName: node linkType: hard -"@babel/helper-skip-transparent-expression-wrappers@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.27.1" +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.29.7" dependencies: - "@babel/traverse": "npm:^7.27.1" - "@babel/types": "npm:^7.27.1" - checksum: 10/4f380c5d0e0769fa6942a468b0c2d7c8f0c438f941aaa88f785f8752c103631d0904c7b4e76207a3b0e6588b2dec376595370d92ca8f8f1b422c14a69aa146d4 + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10/a5800bfcdca6cef7f6fe33ac02a0f05ff33da9746f97806553f249733f7ba8400290a17f3831d7faa5d91656f254ab749931f53c8a29f301d958d7dd00499637 languageName: node linkType: hard -"@babel/helper-string-parser@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-string-parser@npm:7.27.1" - checksum: 10/0ae29cc2005084abdae2966afdb86ed14d41c9c37db02c3693d5022fba9f5d59b011d039380b8e537c34daf117c549f52b452398f576e908fb9db3c7abbb3a00 +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10/4d8ef0ef7105f3d9fe4361137c8f42e5b4c7a52b5380b962762f2a528a1ba89064e2c6236090716ce34b63707b886ae0ebf36b2c2fcc2851f27e652febfc3648 languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/helper-validator-identifier@npm:7.28.5" - checksum: 10/8e5d9b0133702cfacc7f368bf792f0f8ac0483794877c6dca5fcb73810ee138e27527701826fb58a40a004f3a5ec0a2f3c3dd5e326d262530b119918f3132ba7 +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10/2efa42701eb05babf26dff3332109c9e5e1a3400a71fb9e68ee27af28235036a2a72c2494c04bdab3f909075f42a58b2e8271074372bc7f8e79ec02bd364d7a7 languageName: node linkType: hard -"@babel/helper-validator-option@npm:^7.25.7, @babel/helper-validator-option@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-validator-option@npm:7.27.1" - checksum: 10/db73e6a308092531c629ee5de7f0d04390835b21a263be2644276cb27da2384b64676cab9f22cd8d8dbd854c92b1d7d56fc8517cf0070c35d1c14a8c828b0903 +"@babel/helper-validator-option@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-option@npm:7.29.7" + checksum: 10/aeb6aa966f59300d3cc2fea7c68e1dfd7ad011fc10e535c8e2b2de3094b27c859428dc7220f16420350f8b1cde99da120b673be04bcb0c2f37b56258c96bed58 languageName: node linkType: hard -"@babel/helper-wrap-function@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/helper-wrap-function@npm:7.28.6" +"@babel/helper-wrap-function@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-wrap-function@npm:7.29.7" dependencies: - "@babel/template": "npm:^7.28.6" - "@babel/traverse": "npm:^7.28.6" - "@babel/types": "npm:^7.28.6" - checksum: 10/d8a895a75399904746f4127db33593a20021fc55d1a5b5dfeb060b87cc13a8dceea91e70a4951bcd376ba9bd8232b0c04bff9a86c1dab83d691e01852c3b5bcd + "@babel/template": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10/fc37b53a93c0814e5443c344fcd42b343c75856d865547ca0d50ddad73e96c27f6a677330d115232644e143066758188e4eb47ce5207124c095312b9e49599ed languageName: node linkType: hard -"@babel/helpers@npm:^7.25.7, @babel/helpers@npm:^7.28.6": - version: 7.29.2 - resolution: "@babel/helpers@npm:7.29.2" +"@babel/helpers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helpers@npm:7.29.7" dependencies: - "@babel/template": "npm:^7.28.6" - "@babel/types": "npm:^7.29.0" - checksum: 10/ad77706f3f917bd224e037fd0fbc67c45b240d2a45981321b093f70b7c535bee9bbddb0a19e34c362cb000ae21cdd8638f8a87a5f305a5bd7547e93fdcc524fe + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10/b4d1ef12c19e896585c009ba29677839097ff04f8b11a2430d335c3fb6bd667b4f9e96a3b185a083fdde6b1137eabbbf2600c32425cb69cefc81d81d5cfe425d languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.7, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": - version: 7.29.3 - resolution: "@babel/parser@npm:7.29.3" +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/parser@npm:7.29.7" dependencies: - "@babel/types": "npm:^7.29.0" + "@babel/types": "npm:^7.29.7" bin: parser: ./bin/babel-parser.js - checksum: 10/10e8f34e0fdaa495b9db8be71f4eb29b16d8a57e0818c1bb1c4084015b0383803fd77812ed41597760cbf3d9ab3ae9f4af54f39ff5e5d8e081ba43593232f0ca + checksum: 10/da40c5928c95997b01aabe84fc3440881b8f20b866714fefa142961d37e82ffc03fbb9afed706f15f8a688278f95286ca0cea0d87ad6c77600f8c6c45d9824ee languageName: node linkType: hard -"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.7, @babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.28.5" +"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/750de98b34e6d09b545ded6e635b43cbab02fe319622964175259b98f41b16052e5931c4fbd45bad8cd0a37ebdd381233edecec9ee395b8ec51f47f47d1dbcd4 + checksum: 10/c0e85e9b4bf8706f23b58c1794ad398e41b69a639416578fd4c0ef5a5472365a8d1d7a533f94137daf3660e4f710a8b7e10bc8a53a91a41773ec92bf1725af98 languageName: node linkType: hard -"@babel/plugin-bugfix-safari-class-field-initializer-scope@npm:^7.25.7, @babel/plugin-bugfix-safari-class-field-initializer-scope@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-bugfix-safari-class-field-initializer-scope@npm:7.27.1" +"@babel/plugin-bugfix-safari-class-field-initializer-scope@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-bugfix-safari-class-field-initializer-scope@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/eb7f4146dc01f1198ce559a90b077e58b951a07521ec414e3c7d4593bf6c4ab5c2af22242a7e9fec085e20299e0ba6ea97f44a45e84ab148141bf9eb959ad25e + checksum: 10/ad06de66ccfb19f0f04e6124d144f3fef72fa5191861b1d04bc32cab87ce43958810d9632eac5881ef991a78b33e68588e3d90e76a63d917bd5a7ff4c96618f8 languageName: node linkType: hard -"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.25.7, @babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.27.1" +"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/621cfddfcc99a81e74f8b6f9101fd260b27500cb1a568e3ceae9cc8afe9aee45ac3bca3900a2b66c612b1a2366d29ef67d4df5a1c975be727eaad6906f98c2c6 + checksum: 10/2189a2a648948107c59ad3bf028e5b71a85b28c840facfead769a33c5f63ae4ec0f147e6a2e664a91a506d4405f5a8972d2ca628f3b64d03edd7620d770761fb languageName: node linkType: hard -"@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@npm:^7.29.3": - version: 7.29.3 - resolution: "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@npm:7.29.3" +"@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/fd13198afc9b72c6a4e4868f1592fc8010f390e7601148a71d2d6111664c0242d6d5ff27d8eb77ca4c35ef47f8416daf5dbc8d46a498ac706d69c6b3a0988cd7 + checksum: 10/a9bd13c2600bdfcb5b35590e210bcb30f009fda9bd1556567757ea4fcb8ca247750331d6d10774d68127cae4e529bc79abd86a28fe5e2a1cc2cc00e75964ac52 languageName: node linkType: hard -"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:^7.25.7, @babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:7.27.1" +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" - "@babel/plugin-transform-optional-chaining": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.13.0 - checksum: 10/f07aa80272bd7a46b7ba11a4644da6c9b6a5a64e848dfaffdad6f02663adefd512e1aaebe664c4dd95f7ed4f80c872c7f8db8d8e34b47aae0930b412a28711a0 + checksum: 10/76a494ec4f52a127b0208c4574a6da36f6ff5f30484306921762db549fa7d9d9183c837759eb68c0c9e5a56013aa247e6e02e02263384d2a103240353ae0ceb6 languageName: node linkType: hard -"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:^7.25.7, @babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:7.28.6" +"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/traverse": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/9377897aa7cba3a0b78a7c6015799ff71504b2b203329357e42ab3185d44aab07344ba33f5dd53f14d5340c1dc5a2587346343e0859538947bbab0484e72b914 + checksum: 10/73125174671241b3eb1fa7c7f53ed0894d3853f2afb280684492a707cf3e4a9c498537acb511c014d364018117e181bb265dbbb975ea0b1a61a6de60bae8f07e languageName: node linkType: hard @@ -455,58 +422,25 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-dynamic-import@npm:^7.8.3": - version: 7.8.3 - resolution: "@babel/plugin-syntax-dynamic-import@npm:7.8.3" +"@babel/plugin-syntax-import-assertions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-import-assertions@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.0" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/ce307af83cf433d4ec42932329fad25fa73138ab39c7436882ea28742e1c0066626d224e0ad2988724c82644e41601cef607b36194f695cb78a1fcdc959637bd + checksum: 10/a7f24858e7e833c2ee25779a355b5eff46e4bc93c98b06bda7ea0fd12faf99c4954e0f71074485512045920432790e0269aa562dd4d2085c3f8660ed1cfde8a1 languageName: node linkType: hard -"@babel/plugin-syntax-export-namespace-from@npm:^7.8.3": - version: 7.8.3 - resolution: "@babel/plugin-syntax-export-namespace-from@npm:7.8.3" +"@babel/plugin-syntax-import-attributes@npm:^7.24.7, @babel/plugin-syntax-import-attributes@npm:^7.26.0, @babel/plugin-syntax-import-attributes@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-import-attributes@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.8.3" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/85740478be5b0de185228e7814451d74ab8ce0a26fcca7613955262a26e99e8e15e9da58f60c754b84515d4c679b590dbd3f2148f0f58025f4ae706f1c5a5d4a - languageName: node - linkType: hard - -"@babel/plugin-syntax-import-assertions@npm:^7.25.7, @babel/plugin-syntax-import-assertions@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-syntax-import-assertions@npm:7.28.6" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/25017235e1e2c4ed892aa327a3fa10f4209cc618c6dd7806fc40c07d8d7d24a39743d3d5568b8d1c8f416cffe03c174e78874ded513c9338b07a7ab1dcbab050 - languageName: node - linkType: hard - -"@babel/plugin-syntax-import-attributes@npm:7.26.0": - version: 7.26.0 - resolution: "@babel/plugin-syntax-import-attributes@npm:7.26.0" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.9" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/c122aa577166c80ee67f75aebebeef4150a132c4d3109d25d7fc058bf802946f883e330f20b78c1d3e3a5ada631c8780c263d2d01b5dbaecc69efefeedd42916 - languageName: node - linkType: hard - -"@babel/plugin-syntax-import-attributes@npm:^7.24.7, @babel/plugin-syntax-import-attributes@npm:^7.25.7, @babel/plugin-syntax-import-attributes@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-syntax-import-attributes@npm:7.28.6" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/6c8c6a5988dbb9799d6027360d1a5ba64faabf551f2ef11ba4eade0c62253b5c85d44ddc8eb643c74b9acb2bcaa664a950bd5de9a5d4aef291c4f2a48223bb4b + checksum: 10/9f47345d09aae16b7ab52ecaf541cde3e3ae1e57e3eb2d4088e062b29dfbd67db55d42d529840557583d66121e2a98788df7a455401cc6d635c8b7700a02efc9 languageName: node linkType: hard @@ -532,14 +466,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.25.7, @babel/plugin-syntax-jsx@npm:^7.27.1": - version: 7.28.6 - resolution: "@babel/plugin-syntax-jsx@npm:7.28.6" +"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/572e38f5c1bb4b8124300e7e3dd13e82ae84a21f90d3f0786c98cd05e63c78ca1f32d1cfe462dfbaf5e7d5102fa7cd8fd741dfe4f3afc2e01a3b2877dcc8c866 + checksum: 10/84150d27c553a1d3d921354437f6725ca1d63b49514c25591bfcaaafa6ea4d6c10715b66fe7245e4ad7ab7c6cf4b6e1de7373defd3df00877ab12638170d7772 languageName: node linkType: hard @@ -631,14 +565,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-syntax-typescript@npm:7.28.6" +"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/5c55f9c63bd36cf3d7e8db892294c8f85000f9c1526c3a1cc310d47d1e174f5c6f6605e5cc902c4636d885faba7a9f3d5e5edc6b35e4f3b1fd4c2d58d0304fa5 + checksum: 10/ef454d2a7a6209dd4255361c072c94ab1293e7ad4b06e7e744d08bb308065d4d6544964eae9b2357c3b33d8d939f9e32d4aa95905bc464407cd8f7101dee4443 languageName: node linkType: hard @@ -654,815 +588,722 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-arrow-functions@npm:^7.25.7, @babel/plugin-transform-arrow-functions@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-arrow-functions@npm:7.27.1" +"@babel/plugin-transform-arrow-functions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-arrow-functions@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/62c2cc0ae2093336b1aa1376741c5ed245c0987d9e4b4c5313da4a38155509a7098b5acce582b6781cc0699381420010da2e3086353344abe0a6a0ec38961eb7 + checksum: 10/0037fd7563c7c91cddb8ce104e270bc260190d29c7a297df65e4306471010b4343366de13fab4602cf8ee6c672d3b313b34a01b62f27a333cad16908c83368d8 languageName: node linkType: hard -"@babel/plugin-transform-async-generator-functions@npm:^7.25.7, @babel/plugin-transform-async-generator-functions@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/plugin-transform-async-generator-functions@npm:7.29.0" +"@babel/plugin-transform-async-generator-functions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-async-generator-functions@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-remap-async-to-generator": "npm:^7.27.1" - "@babel/traverse": "npm:^7.29.0" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-remap-async-to-generator": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/e2c064a5eb212cbdf14f7c0113e069b845ca0f0ba431c1cc04607d3fc4f3bf1ed70f5c375fe7c61338a45db88bc1a79d270c8d633ce12256e1fce3666c1e6b93 + checksum: 10/0abb8d8bb21e7293b3eda5a743051678478ab7c0392310a4a9e0417125a2bb8536a0a5f41a8062211d995479339afa7ffab6b0141f0839af8fbba367dd6a99c5 languageName: node linkType: hard -"@babel/plugin-transform-async-to-generator@npm:^7.25.7, @babel/plugin-transform-async-to-generator@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-async-to-generator@npm:7.28.6" +"@babel/plugin-transform-async-to-generator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-async-to-generator@npm:7.29.7" dependencies: - "@babel/helper-module-imports": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-remap-async-to-generator": "npm:^7.27.1" + "@babel/helper-module-imports": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-remap-async-to-generator": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/bca5774263ec01dd2bf71c74bbaf7baa183bf03576636b7826c3346be70c8c8cb15cff549112f2983c36885131a0afde6c443591278c281f733ee17f455aa9b1 + checksum: 10/e24db9c4c69121daab25883f9a96e6849fa664c78c6bbcfb77fe0a0c6ab29b81ba39e8497985367463d3a88deac3b5bbe15dd1c5d0e5dd492cfccf8efdd27452 languageName: node linkType: hard -"@babel/plugin-transform-block-scoped-functions@npm:^7.25.7, @babel/plugin-transform-block-scoped-functions@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.27.1" +"@babel/plugin-transform-block-scoped-functions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/7fb4988ca80cf1fc8345310d5edfe38e86b3a72a302675cdd09404d5064fe1d1fe1283ebe658ad2b71445ecef857bfb29a748064306b5f6c628e0084759c2201 + checksum: 10/24f9712ade98061cd22088709b86b8e3e19ea416dbe5a69ad504fc3f8f2179af5bdeb32fcb9c24fc861bef515e41ae5ef72cd909e0e42bb0cf15838c4e737149 languageName: node linkType: hard -"@babel/plugin-transform-block-scoping@npm:^7.25.7, @babel/plugin-transform-block-scoping@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-block-scoping@npm:7.28.6" +"@babel/plugin-transform-block-scoping@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-block-scoping@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/7ab8a0856024a5360ba16c3569b739385e939bc5a15ad7d811bec8459361a9aa5ee7c5f154a4e2ce79f5d66779c19464e7532600c31a1b6f681db4eb7e1c7bde + checksum: 10/9c3cbbdda288b2eff7355ab94fc7b4b18f408d8e7145c2d6bd34e70eef03f200c699f527318ac11d9cd6e99124b5e8d6aeeba4421346ee5cdc224d06175d984f languageName: node linkType: hard -"@babel/plugin-transform-class-properties@npm:^7.25.7, @babel/plugin-transform-class-properties@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-class-properties@npm:7.28.6" +"@babel/plugin-transform-class-properties@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-class-properties@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/200f30d44b36a768fa3a8cf690db9e333996af2ad14d9fa1b4c91a427ed9302907873b219b4ce87517ca1014a810eb2e929a6a66be68473f72b546fc64d04fbc + checksum: 10/0cc5e7a882e29eead360f02ef79f6b2ec3b3813213b1513d8fdaa931d1d1361fccc92fbacc9b399e42495953d9d6fc722f283b5f3aa272fe016a0b5fe1e6a130 languageName: node linkType: hard -"@babel/plugin-transform-class-static-block@npm:^7.25.7, @babel/plugin-transform-class-static-block@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-class-static-block@npm:7.28.6" +"@babel/plugin-transform-class-static-block@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-class-static-block@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.12.0 - checksum: 10/bea7836846deefd02d9976ad1b30b5ade0d6329ecd92866db789dcf6aacfaf900b7a77031e25680f8de5ad636a771a5bdca8961361e6218d45d538ec5d9b71cc + checksum: 10/9e7112dafb0e7791de3858cb721a76147e8cfc9b6ba370dd0267bdc193abdbe8a8f78db8d70e0f860d03497d861f0ac7e8cd3e8caf2639c59b57fc74eb3b95d0 languageName: node linkType: hard -"@babel/plugin-transform-classes@npm:^7.25.7, @babel/plugin-transform-classes@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-classes@npm:7.28.6" +"@babel/plugin-transform-classes@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-classes@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.27.3" - "@babel/helper-compilation-targets": "npm:^7.28.6" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-replace-supers": "npm:^7.28.6" - "@babel/traverse": "npm:^7.28.6" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-globals": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-replace-supers": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/9c3278a314d1c4bcda792bb22aced20e30c735557daf9bcc56397c0f3eb54761b21c770219e4581036a10dabda3e597321ed093bc245d5f4d561e19ceff66a6d + checksum: 10/ac6387c6bfb9d9b9f9d0702050fa8833e76c123d607bdba1af8d991f691e01a0652130954baa53051f0e16b8a0545e3f3c5a5bc4404a19abac0af2eee748f898 languageName: node linkType: hard -"@babel/plugin-transform-computed-properties@npm:^7.25.7, @babel/plugin-transform-computed-properties@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-computed-properties@npm:7.28.6" +"@babel/plugin-transform-computed-properties@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-computed-properties@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/template": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/4a5e270f7e1f1e9787cf7cf133d48e3c1e38eb935d29a90331a1324d7c720f589b7b626b2e6485cd5521a7a13f2dbdc89a3e46ecbe7213d5bbb631175267c4aa + checksum: 10/c982355904fc113334ba108282c8a617e3159c6960de717bbe7c5fa86ff777baea04c117edc692fb4de22b01460bfedc62af3c6a9d0ecd81511f5eb8357d8bab languageName: node linkType: hard -"@babel/plugin-transform-destructuring@npm:^7.25.7, @babel/plugin-transform-destructuring@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/plugin-transform-destructuring@npm:7.28.5" +"@babel/plugin-transform-destructuring@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-destructuring@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/9cc67d3377bc5d8063599f2eb4588f5f9a8ab3abc9b64a40c24501fb3c1f91f4d5cf281ea9f208fd6b2ef8d9d8b018dacf1bed9493334577c966cd32370a7036 + checksum: 10/e53caad0c2d523367724aefcc6b8c8df24fcb1a3f53e9899cb4bb5dc39ccf61e30df0a761d74485a895d9bcaaad49644879cbd3a9fc20c90501a5e831caaac5b languageName: node linkType: hard -"@babel/plugin-transform-dotall-regex@npm:^7.25.7, @babel/plugin-transform-dotall-regex@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-dotall-regex@npm:7.28.6" +"@babel/plugin-transform-dotall-regex@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-dotall-regex@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-regexp-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/866ffbbdee77fa955063b37c75593db8dbbe46b1ebb64cc788ea437e3a9aa41cb7b9afcee617c678a32b6705baa0892ec8e5d4b8af3bbb0ab1b254514ccdbd37 + checksum: 10/537df0fb915a420df715a2f4da16ab6c08ce2370521edef9a8a59af23a533314109f6abd836c5e127c8cea4a46bc4a05692670cf7ad64868c286075fa2d7848c languageName: node linkType: hard -"@babel/plugin-transform-duplicate-keys@npm:^7.25.7, @babel/plugin-transform-duplicate-keys@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-duplicate-keys@npm:7.27.1" +"@babel/plugin-transform-duplicate-keys@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-duplicate-keys@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/987b718d2fab7626f61b72325c8121ead42341d6f46ad3a9b5e5f67f3ec558c903f1b8336277ffc43caac504ce00dd23a5456b5d1da23913333e1da77751f08d + checksum: 10/f0e7ad459dd9c78514c07a576fa509478aa9c7e90c1d7cf3b1d142579f8dadd609878c10b044dd2a3a2b08adbdb51b108fcb261d9a78443e13494a7985f9c2a7 languageName: node linkType: hard -"@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:^7.25.7, @babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:7.29.0" +"@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-regexp-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/7fa7b773259a578c9e01c80946f75ecc074520064aa7a87a65db06c7df70766e2fa6be78cda55fa9418a14e30b2b9d595484a46db48074d495d9f877a4276065 + checksum: 10/fa7fcdbede10dbc06fe4f861e90286f7f599d3f3c43e69445e63c3f48422fd34db0b0dd9bbebccd1dbd04a50fca4ab22fd82d28e7bc8fe9b6d5790c9e694d380 languageName: node linkType: hard -"@babel/plugin-transform-dynamic-import@npm:^7.25.7, @babel/plugin-transform-dynamic-import@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-dynamic-import@npm:7.27.1" +"@babel/plugin-transform-dynamic-import@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-dynamic-import@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/7a9fbc8d17148b7f11a1d1ca3990d2c2cd44bd08a45dcaf14f20a017721235b9044b20e6168b6940282bb1b48fb78e6afbdfb9dd9d82fde614e15baa7d579932 + checksum: 10/58c035e6b9103c225f3d181671d9b3dec140351c3ecf12bc3a66b8653be41161f20135ada00a703244c2bfc9dd57b62fc54f77f2f6fe43df6c598358f377e6fa languageName: node linkType: hard -"@babel/plugin-transform-explicit-resource-management@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-explicit-resource-management@npm:7.28.6" +"@babel/plugin-transform-explicit-resource-management@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-explicit-resource-management@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/plugin-transform-destructuring": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/plugin-transform-destructuring": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/36d638a253dbdaee5548b4ddd21c04ee4e39914b207437bb64cf79bb41c2caadb4321768d3dba308c1016702649bc44efe751e2052de393004563c7376210d86 + checksum: 10/a4ea28a18161a7e8c8f33731cb3dfc6981cb089b9a6b57abfa7ff7579a0ca76a4c19c29ebaf775b037ff31503c74c6cf3687ce86cdaa246d1c60afb5abd820df languageName: node linkType: hard -"@babel/plugin-transform-exponentiation-operator@npm:^7.25.7, @babel/plugin-transform-exponentiation-operator@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.28.6" +"@babel/plugin-transform-exponentiation-operator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/b232152499370435c7cd4bf3321f58e189150e35ca3722ea16533d33434b97294df1342f5499671ec48e62b71c34cdea0ca8cf317ad12594a10f6fc670315e62 + checksum: 10/bf0366d77d318d4b6eae6880217e3fdfcb6e5f7913f658583de9537fd4fca1f05f9ac4083d8f946a84eaefec068cbba948be90f4a39484c85bc69082def3162d languageName: node linkType: hard -"@babel/plugin-transform-export-namespace-from@npm:^7.25.7, @babel/plugin-transform-export-namespace-from@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-export-namespace-from@npm:7.27.1" +"@babel/plugin-transform-export-namespace-from@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-export-namespace-from@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/85082923eca317094f08f4953d8ea2a6558b3117826c0b740676983902b7236df1f4213ad844cb38c2dae104753dbe8f1cc51f01567835d476d32f5f544a4385 + checksum: 10/d157d62b144d1626b801e557dcede914db33e78f3f4230f487e5709c21efd40648f7f3a47a44a7fce694ad9cd117d2ac3ed796da0102275fd02b4fdb317d906b languageName: node linkType: hard -"@babel/plugin-transform-for-of@npm:^7.25.7, @babel/plugin-transform-for-of@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-for-of@npm:7.27.1" +"@babel/plugin-transform-for-of@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-for-of@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/705c591d17ef263c309bba8c38e20655e8e74ff7fd21883a9cdaf5bf1df42d724383ad3d88ac01f42926e15b1e1e66f2f7f8c4e87de955afffa290d52314b019 + checksum: 10/af79d2859f7330c4b47fefc49a7849fb651ef062532beafdba5500fc13a46b9dfe1a853a27b73257a092d7e65a7b9956a8df9971c9908825d2f3b86b00c35c38 languageName: node linkType: hard -"@babel/plugin-transform-function-name@npm:^7.25.7, @babel/plugin-transform-function-name@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-function-name@npm:7.27.1" +"@babel/plugin-transform-function-name@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-function-name@npm:7.29.7" dependencies: - "@babel/helper-compilation-targets": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/traverse": "npm:^7.27.1" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/26a2a183c3c52a96495967420a64afc5a09f743a230272a131668abf23001e393afa6371e6f8e6c60f4182bea210ed31d1caf866452d91009c1daac345a52f23 + checksum: 10/87a6329442ef8085fbc160e659f64562f0f5b63be65403b8bbb8e18c67dd7d03b82fb2e1371fdde734e2f05b13a72f88ed8d8cb03807150063954b9604d082cf languageName: node linkType: hard -"@babel/plugin-transform-json-strings@npm:^7.25.7, @babel/plugin-transform-json-strings@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-json-strings@npm:7.28.6" +"@babel/plugin-transform-json-strings@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-json-strings@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/69d82a1a0a72ed6e6f7969e09cf330516599d79b2b4e680e9dd3c57616a8c6af049b5103456e370ab56642815e80e46ed88bb81e9e059304a85c5fe0bf137c29 + checksum: 10/3af97e144e0d986522f01803ffa0f90741530d1610952ac6a92511c356ecbf5616092ab1d21401d25bc7cb8ac90d73c2c7ff35e41766df2708c29d7e588cfa7f languageName: node linkType: hard -"@babel/plugin-transform-literals@npm:^7.25.7, @babel/plugin-transform-literals@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-literals@npm:7.27.1" +"@babel/plugin-transform-literals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-literals@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/0a76d12ab19f32dd139964aea7da48cecdb7de0b75e207e576f0f700121fe92367d788f328bf4fb44b8261a0f605c97b44e62ae61cddbb67b14e94c88b411f95 + checksum: 10/aadb2b3fe85186c274a07d5486aeef9496ce374e534fbc7b54f77985c75513422d9acec4c532f67b027e939644d93a69c00505b8909e259184c3ee5c5c62c46b languageName: node linkType: hard -"@babel/plugin-transform-logical-assignment-operators@npm:^7.25.7, @babel/plugin-transform-logical-assignment-operators@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.28.6" +"@babel/plugin-transform-logical-assignment-operators@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/36095d5d1cfc680e95298b5389a16016da800ae3379b130dabf557e94652c47b06610407e9fa44aaa03e9b0a5aa7b4b93348123985d44a45e369bf5f3497d149 + checksum: 10/374d83dfbb2de5e339d2966487c0f0d766cd67422addbd0172104ff2788b60317858172a7ab2cb6ee7d5bffad72ce07120fec009a2ef3b35551013fce4908686 languageName: node linkType: hard -"@babel/plugin-transform-member-expression-literals@npm:^7.25.7, @babel/plugin-transform-member-expression-literals@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-member-expression-literals@npm:7.27.1" +"@babel/plugin-transform-member-expression-literals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-member-expression-literals@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/804121430a6dcd431e6ffe99c6d1fbbc44b43478113b79c677629e7f877b4f78a06b69c6bfb2747fd84ee91879fe2eb32e4620b53124603086cf5b727593ebe8 + checksum: 10/698cbc9500e8caea1d36b48248112d60e023cea9d0772ac1ecf1639b38b662d9352043747dbe617fe1f6f17709bc17601534f7bf2f22c791fb86f7e2ab43e39f languageName: node linkType: hard -"@babel/plugin-transform-modules-amd@npm:^7.25.7, @babel/plugin-transform-modules-amd@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-modules-amd@npm:7.27.1" +"@babel/plugin-transform-modules-amd@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-amd@npm:7.29.7" dependencies: - "@babel/helper-module-transforms": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/5ca9257981f2bbddd9dccf9126f1368de1cb335e7a5ff5cca9282266825af5b18b5f06c144320dcf5d2a200d2b53b6d22d9b801a55dc0509ab5a5838af7e61b7 + checksum: 10/a25cfc84db14a943cee1717030114e052152487af8784c015762a4fc11ee7b45127036c074044636b10c7251eb310c172904028a6f36abcae816e0cc685553b8 languageName: node linkType: hard -"@babel/plugin-transform-modules-commonjs@npm:^7.25.7, @babel/plugin-transform-modules-commonjs@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-modules-commonjs@npm:7.28.6" +"@babel/plugin-transform-modules-commonjs@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7" dependencies: - "@babel/helper-module-transforms": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/ec6ea2958e778a7e0220f4a75cb5816cecddc6bd98efa10499fff7baabaa29a594d50d787a4ebf8a8ba66fefcf76ca2ded602be0b4554ae3317e53b3b3375b37 + checksum: 10/7d816febde2d65bde5607ef355d751ba6c5a2d68ffe47c37b809e3ed2f829603751d4b5a5506f4299936d95fc73909243f9074f98dd32201277ec4131fc3ff33 languageName: node linkType: hard -"@babel/plugin-transform-modules-systemjs@npm:^7.29.4": - version: 7.29.4 - resolution: "@babel/plugin-transform-modules-systemjs@npm:7.29.4" +"@babel/plugin-transform-modules-systemjs@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-systemjs@npm:7.29.7" dependencies: - "@babel/helper-module-transforms": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-validator-identifier": "npm:^7.28.5" - "@babel/traverse": "npm:^7.29.0" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/79269e6ec8ec831bb63bf1c7cc1a980e28da785e92b36d42612f0139e4044499b99aa109fca849e1a156c092aabf6c24d145f4cabf2ac9ea84ef468852fe4c03 + checksum: 10/084759e221428b52d888da594d65293f0f888b774398a6431f5a6a5f355a9a3accb8ba0cb123d54c7aafd24cbfe82613ee64939e4600b38a78393cbbea8979ac languageName: node linkType: hard -"@babel/plugin-transform-modules-umd@npm:^7.25.7, @babel/plugin-transform-modules-umd@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-modules-umd@npm:7.27.1" +"@babel/plugin-transform-modules-umd@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-umd@npm:7.29.7" dependencies: - "@babel/helper-module-transforms": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/7388932863b4ee01f177eb6c2e2df9e2312005e43ada99897624d5565db4b9cef1e30aa7ad2c79bbe5373f284cfcddea98d8fe212714a24c6aba223272163058 + checksum: 10/74024ae1ecb702a766af6b3131a44be8b50d3614860ba857f8bb1e29c38acbecb14f66e2847082ccc5e188547aaa9678d9bbd67c20cef6f2e597f977a81f34dd languageName: node linkType: hard -"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.25.7, @babel/plugin-transform-named-capturing-groups-regex@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.29.0" +"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-regexp-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/ed8c27699ca82a6c01cbfd39f3de16b90cfea4f8146a358057f76df290d308a66a8bd2e6734e6a87f68c18576e15d2d70548a84cd474d26fdf256c3f5ae44d8c + checksum: 10/3a481be133e9ca5d25570b5ed62daae323a51663bacf30fed0d1980e912047ebd34d6326533182db625fc0bbd086d1a23ed68ebb6108e7a68a8650c228afc084 languageName: node linkType: hard -"@babel/plugin-transform-new-target@npm:^7.25.7, @babel/plugin-transform-new-target@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-new-target@npm:7.27.1" +"@babel/plugin-transform-new-target@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-new-target@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/620d78ee476ae70960989e477dc86031ffa3d554b1b1999e6ec95261629f7a13e5a7b98579c63a009f9fdf14def027db57de1f0ae1f06fb6eaed8908ff65cf68 + checksum: 10/6ef505da7107e46afe170a7c3dc69a2afb2a58276c172189cbba86ed669e64bf2884a16deb007257af399fcc3c2381c688b8bbed612c986ded82ac9fa43ab5b1 languageName: node linkType: hard -"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.25.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.28.6" +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/88106952ca4f4fea8f97222a25f9595c6859d458d76905845dfa54f54e7d345e3dc338932e8c84a9c57a6c88b2f6d9ebff47130ce508a49c2b6e6a9f03858750 + checksum: 10/4bff79355c240342e18e02cee282ce5c30bafa4335a250f4a47e822fde6def70afa19708e04fec3cc942252da16ea3a28a21279180cc92734c2a2d9826600bb3 languageName: node linkType: hard -"@babel/plugin-transform-numeric-separator@npm:^7.25.7, @babel/plugin-transform-numeric-separator@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-numeric-separator@npm:7.28.6" +"@babel/plugin-transform-numeric-separator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-numeric-separator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/4b5ca60e481e22f0842761a3badca17376a230b5a7e5482338604eb95836c2d0c9c9bde53bdc5c2de1c6a12ae6c12de7464d098bf74b0943f85905ca358f0b68 + checksum: 10/6177df9e1a8a190bf4a360f8cbcfa614b70ededba61201bd0a38929770b6d00a8a54a19f8fda82cf60688d9bab938427a9fe5dae0a9e8cd8ed79c88a3b2dbcd7 languageName: node linkType: hard -"@babel/plugin-transform-object-rest-spread@npm:^7.25.7, @babel/plugin-transform-object-rest-spread@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-object-rest-spread@npm:7.28.6" +"@babel/plugin-transform-object-rest-spread@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-object-rest-spread@npm:7.29.7" dependencies: - "@babel/helper-compilation-targets": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/plugin-transform-destructuring": "npm:^7.28.5" - "@babel/plugin-transform-parameters": "npm:^7.27.7" - "@babel/traverse": "npm:^7.28.6" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/plugin-transform-destructuring": "npm:^7.29.7" + "@babel/plugin-transform-parameters": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/9c8c51a515a5ec98a33a715e82d49f873e58b04b53fa1e826f3c2009f7133cd396d6730553a53d265e096dbfbea17dd100ae38815d0b506c094cb316a7a5519e + checksum: 10/36b906179d21a2a3287fac5283b25584bd7b79eb2707c62fa8e75ab79b21b4e11b2670e6b2eafb99fb448495153587dc5cdeec1d8433ba175060e5c8101292b0 languageName: node linkType: hard -"@babel/plugin-transform-object-super@npm:^7.25.7, @babel/plugin-transform-object-super@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-object-super@npm:7.27.1" +"@babel/plugin-transform-object-super@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-object-super@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/helper-replace-supers": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-replace-supers": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/46b819cb9a6cd3cfefe42d07875fee414f18d5e66040366ae856116db560ad4e16f3899a0a7fddd6773e0d1458444f94b208b67c0e3b6977a27ea17a5c13dbf6 + checksum: 10/65ed8719563572c4917f19b32c476c9a9062fccf89bfd44a418bb734cd866034d66049499cace58e2bb4589da779983f534078bd989333f36268b9da08d4b33c languageName: node linkType: hard -"@babel/plugin-transform-optional-catch-binding@npm:^7.25.7, @babel/plugin-transform-optional-catch-binding@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.28.6" +"@babel/plugin-transform-optional-catch-binding@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/ee24a17defec056eb9ef01824d7e4a1f65d531af6b4b79acfd0bcb95ce0b47926e80c61897f36f8c01ce733b069c9acdb1c9ce5ec07a729d0dbf9e8d859fe992 + checksum: 10/4b6e41e1dc5dbd02cfe0b96214130cca5fd3bd879551fc82188bb3d9a2782af9bab50f2140af9ff946a8ee23b9478ee42810641fb99aa3e033884d7c6103d138 languageName: node linkType: hard -"@babel/plugin-transform-optional-chaining@npm:^7.25.7, @babel/plugin-transform-optional-chaining@npm:^7.27.1, @babel/plugin-transform-optional-chaining@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-optional-chaining@npm:7.28.6" +"@babel/plugin-transform-optional-chaining@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/c7cf29f99384a9a98748f04489a122c0106e0316aa64a2e61ef8af74c1057b587b96d9a08eb4e33d2ac17d1aaff1f0a86fae658d429fa7bcce4ef977e0ad684b + checksum: 10/2fd8f0135d8b051e4873ba097443bd753abd12a32f910fd19ae4c2f94a183129cf0936aed7aa14b417a68752aa1eec7a9fe43befdab736dbb24bbf192b7e5edc languageName: node linkType: hard -"@babel/plugin-transform-parameters@npm:^7.25.7, @babel/plugin-transform-parameters@npm:^7.27.7": - version: 7.27.7 - resolution: "@babel/plugin-transform-parameters@npm:7.27.7" +"@babel/plugin-transform-parameters@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-parameters@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/ba0aa8c977a03bf83030668f64c1d721e4e82d8cce89cdde75a2755862b79dbe9e7f58ca955e68c721fd494d6ee3826e46efad3fbf0855fcc92cb269477b4777 + checksum: 10/05faa7bbe1ae81eda6ab9bfca35476d7e55049b1ad182471a6e5a45a888ef1208977a0cbe0ee23b6920d4754d2386cd94026d705e32acff7a036b9db4110b566 languageName: node linkType: hard -"@babel/plugin-transform-private-methods@npm:^7.25.7, @babel/plugin-transform-private-methods@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-private-methods@npm:7.28.6" +"@babel/plugin-transform-private-methods@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-private-methods@npm:7.29.7" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/b80179b28f6a165674d0b0d6c6349b13a01dd282b18f56933423c0a33c23fc0626c8f011f859fc20737d021fe966eb8474a5233e4596401482e9ee7fb00e2aa2 + checksum: 10/5055af2b86a95acbf6bd1a14256edf439ba4f214707f6aa9f6a29d1168c882e5709853c4ff225f55575f88d3a58effbed952d25c5cd70f93785106541f992cdd languageName: node linkType: hard -"@babel/plugin-transform-private-property-in-object@npm:^7.25.7, @babel/plugin-transform-private-property-in-object@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-private-property-in-object@npm:7.28.6" +"@babel/plugin-transform-private-property-in-object@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-private-property-in-object@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.27.3" - "@babel/helper-create-class-features-plugin": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/d02008c62fd32ff747b850b8581ab5076b717320e1cb01c7fc66ebf5169095bd922e18cfb269992f85bc7fbd2cc61e5b5af25e2b54aad67411474b789ea94d5f + checksum: 10/72464fe21457daa2002b8fb8ab222dfc4a5fa4df33b99a371ca5594a28c933491d4373349cbff95caff33d8b5506d0350b1b7b0f4fde04ebd1defcdd5d7d9751 languageName: node linkType: hard -"@babel/plugin-transform-property-literals@npm:^7.25.7, @babel/plugin-transform-property-literals@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-property-literals@npm:7.27.1" +"@babel/plugin-transform-property-literals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-property-literals@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/7caec27d5ed8870895c9faf4f71def72745d69da0d8e77903146a4e135fd7bed5778f5f9cebb36c5fba86338e6194dd67a08c033fc84b4299b7eceab6d9630cb + checksum: 10/7e7a557df8feb50cd6913158c6d12df0e8a9da58e3f73e7cbfc08af399055b03e77a22b12c73d5bf6bb49239617d6189ccb6021ab03f0225bc54f9c74a880b62 languageName: node linkType: hard -"@babel/plugin-transform-react-jsx@npm:7.25.7": - version: 7.25.7 - resolution: "@babel/plugin-transform-react-jsx@npm:7.25.7" +"@babel/plugin-transform-react-jsx@npm:^7.25.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.25.7" - "@babel/helper-module-imports": "npm:^7.25.7" - "@babel/helper-plugin-utils": "npm:^7.25.7" - "@babel/plugin-syntax-jsx": "npm:^7.25.7" - "@babel/types": "npm:^7.25.7" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-module-imports": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/plugin-syntax-jsx": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/9f87990b39c68dc6441b55bf9b530c89e8cfc7a610e250dfd8002d94a6b806a585fe7cc9318540e4e635eb819fdaf15a42fd5e8a2ec3f8949bd7a5c759b558d3 + checksum: 10/ad735e6666e296404fd3c55378ad9ac712816a9ba3292bf491fd44e2d7bf32217a02c70fd8977c5ae07a246cfdbd75ea3bf5906e69af5dc1d0f404bca09aa7bf languageName: node linkType: hard -"@babel/plugin-transform-regenerator@npm:^7.25.7, @babel/plugin-transform-regenerator@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/plugin-transform-regenerator@npm:7.29.0" +"@babel/plugin-transform-regenerator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-regenerator@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/c8fa9da74371568c5d34fd7d53de018752550cb10334040ca59e41f34b27f127974bdc5b4d1a1a8e8f3ebcf3cb7f650aa3f2df3b7bf1b7edf67c04493b9e3cb8 + checksum: 10/dafb7bfe8e3be6bb4f4f042b2191e5f635c9715f3b41c104ecc5e3919a0b53889569e37db8ff40463bbceed85a593a5010977051ec13f826861ba99e4da46eed languageName: node linkType: hard -"@babel/plugin-transform-regexp-modifiers@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-regexp-modifiers@npm:7.28.6" +"@babel/plugin-transform-regexp-modifiers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-regexp-modifiers@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-regexp-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/5aacc570034c085afa0165137bb9a04cd4299b86eb9092933a96dcc1132c8f591d9d534419988f5f762b2f70d43a3c719a6b8fa05fdd3b2b1820d01cf85500da + checksum: 10/bfbc6b898ace99b8412eb6e902b90856188c692e69672d42d48c5e22a5bf9b0d15d35424fda8501bfb06ba0504acc5f1b9e13eacaba232aa58af74472d6068dc languageName: node linkType: hard -"@babel/plugin-transform-reserved-words@npm:^7.25.7, @babel/plugin-transform-reserved-words@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-reserved-words@npm:7.27.1" +"@babel/plugin-transform-reserved-words@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-reserved-words@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/dea0b66742d2863b369c06c053e11e15ba785892ea19cccf7aef3c1bdaa38b6ab082e19984c5ea7810d275d9445c5400fcc385ad71ce707ed9256fadb102af3b + checksum: 10/ffd7f86ddce36c6ded2dd9218f81be8cbae35b1ed01100ac0a98623bd0a76c059188b70953ab5accae8f8430451e8ed4779d533ac5e35c523f5004a981208b7c languageName: node linkType: hard -"@babel/plugin-transform-runtime@npm:7.25.7": - version: 7.25.7 - resolution: "@babel/plugin-transform-runtime@npm:7.25.7" +"@babel/plugin-transform-runtime@npm:^7.25.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-runtime@npm:7.29.7" dependencies: - "@babel/helper-module-imports": "npm:^7.25.7" - "@babel/helper-plugin-utils": "npm:^7.25.7" - babel-plugin-polyfill-corejs2: "npm:^0.4.10" - babel-plugin-polyfill-corejs3: "npm:^0.10.6" - babel-plugin-polyfill-regenerator: "npm:^0.6.1" + "@babel/helper-module-imports": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + babel-plugin-polyfill-corejs2: "npm:^0.4.14" + babel-plugin-polyfill-corejs3: "npm:^0.13.0" + babel-plugin-polyfill-regenerator: "npm:^0.6.5" semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/45a829b6748758f311e483ead6e874a8cc885a4cdb258582fd01221d055649f3ccf08e0eee8d2d6611c05affdde3bbda354c08ad0a6b6258c2d45d87399b7660 + checksum: 10/4b0b4702e523440530cd0d166b73866470609969afb41b62b2ce3b51a007cad509c77ad21a1bdc62732470d212600cbb6604ca3b8ee89c9e3c6af2f0cd1a63bc languageName: node linkType: hard -"@babel/plugin-transform-shorthand-properties@npm:^7.25.7, @babel/plugin-transform-shorthand-properties@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-shorthand-properties@npm:7.27.1" +"@babel/plugin-transform-shorthand-properties@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-shorthand-properties@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/fbba6e2aef0b69681acb68202aa249c0598e470cc0853d7ff5bd0171fd6a7ec31d77cfabcce9df6360fc8349eded7e4a65218c32551bd3fc0caaa1ac899ac6d4 + checksum: 10/c57ef27853f334a6147da9aa00f8a8f4c3a1c217eb2efa73cba2e118edda10754fa23cec2c0c7f7408279ad28fef92c1f663dfec137a7503813331569c3e02f9 languageName: node linkType: hard -"@babel/plugin-transform-spread@npm:^7.25.7, @babel/plugin-transform-spread@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-spread@npm:7.28.6" +"@babel/plugin-transform-spread@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-spread@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/1fa02ac60ae5e49d46fa2966aaf3f7578cf37255534c2ecf379d65855088a1623c3eea28b9ee6a0b1413b0199b51f9019d0da3fe9da89986bc47e07242415f60 + checksum: 10/c1a12c24de3c330010901b36d48b49e83288951fd92691111e09c93bdae17b1b6b52c13b0e85bcbb9f5d4c72f61648ea8432af332c7049a67d504892122e5f85 languageName: node linkType: hard -"@babel/plugin-transform-sticky-regex@npm:^7.25.7, @babel/plugin-transform-sticky-regex@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-sticky-regex@npm:7.27.1" +"@babel/plugin-transform-sticky-regex@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-sticky-regex@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/e1414a502efba92c7974681767e365a8cda6c5e9e5f33472a9eaa0ce2e75cea0a9bef881ff8dda37c7810ad902f98d3c00ead92a3ac3b73a79d011df85b5a189 + checksum: 10/16b570c0270a59c2a29b2118c00e463882e9dda49b51a17dde3ae6b2886995d49f4bf2161a4c743ad1bca39d2aeb3ac963400e095682e105c7f751e6a2156c28 languageName: node linkType: hard -"@babel/plugin-transform-template-literals@npm:^7.25.7, @babel/plugin-transform-template-literals@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-template-literals@npm:7.27.1" +"@babel/plugin-transform-template-literals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-template-literals@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/93aad782503b691faef7c0893372d5243df3219b07f1f22cfc32c104af6a2e7acd6102c128439eab15336d048f1b214ca134b87b0630d8cd568bf447f78b25ce + checksum: 10/d1014dab020f0f802089de17bba82d929eda6ac87fde5f58fb9763885b8d645ce63fc1df97055c06a12d95a8788334a93b559fcaf1da6d7777a191e5f9c5646e languageName: node linkType: hard -"@babel/plugin-transform-typeof-symbol@npm:^7.25.7, @babel/plugin-transform-typeof-symbol@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-typeof-symbol@npm:7.27.1" +"@babel/plugin-transform-typeof-symbol@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-typeof-symbol@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/812d736402a6f9313b86b8adf36740394400be7a09c48e51ee45ab4a383a3f46fc618d656dd12e44934665e42ae71cf143e25b95491b699ef7c737950dbdb862 + checksum: 10/a22bbe6c46cc4b5dda7aeb907d0263f3c3f93763c0ea6f5cac3c511673ec0be3fc6d9f3d9e65450b14e231cb4ae1cdead29ca9e6b4a94d30485baeab50513f85 languageName: node linkType: hard -"@babel/plugin-transform-typescript@npm:^7.25.7": - version: 7.28.6 - resolution: "@babel/plugin-transform-typescript@npm:7.28.6" +"@babel/plugin-transform-typescript@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-typescript@npm:7.29.7" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.27.3" - "@babel/helper-create-class-features-plugin": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" - "@babel/plugin-syntax-typescript": "npm:^7.28.6" + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + "@babel/plugin-syntax-typescript": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/a0bccc531fa8710a45b0b593140273741e0e4a0721b1ef6ef9dfefae0bbe61528440d65aab7936929551fd76793272257d74f60cf66891352f793294930a4b67 + checksum: 10/88d39bcdfee418795ad65097fff88f491e87793b958fe334273a20f74693b64369456a6d666eb0f1e98c9ecb633c99bcccf90562593e64eb92cc9dd040f25c1c languageName: node linkType: hard -"@babel/plugin-transform-unicode-escapes@npm:^7.25.7, @babel/plugin-transform-unicode-escapes@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-unicode-escapes@npm:7.27.1" +"@babel/plugin-transform-unicode-escapes@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-unicode-escapes@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/87b9e49dee4ab6e78f4cdcdbdd837d7784f02868a96bfc206c8dbb17dd85db161b5a0ecbe95b19a42e8aea0ce57e80249e1facbf9221d7f4114d52c3b9136c9e + checksum: 10/69ae159d4c7b5518c8009e96e406c5110c8238412d5f120b382c6f4fa2ff1226c3951d3fdc835461b81b29af78fe9131da51100b8fd47ef7eefe27d7d6d18b29 languageName: node linkType: hard -"@babel/plugin-transform-unicode-property-regex@npm:^7.25.7, @babel/plugin-transform-unicode-property-regex@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-unicode-property-regex@npm:7.28.6" +"@babel/plugin-transform-unicode-property-regex@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-unicode-property-regex@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-regexp-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/d14e8c51aa73f592575c1543400fd67d96df6410d75c9dc10dd640fd7eecb37366a2f2368bbdd7529842532eda4af181c921bda95146c6d373c64ea59c6e9991 + checksum: 10/03ee0b5d27eee08ba71bc09e919eb648094123985b7adc7dc875e4172100596a47ab68337abf6814cbd3140c6552cf1044135eb0ec1ec78345e1270e5e6aabba languageName: node linkType: hard -"@babel/plugin-transform-unicode-regex@npm:^7.25.7, @babel/plugin-transform-unicode-regex@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-unicode-regex@npm:7.27.1" +"@babel/plugin-transform-unicode-regex@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-unicode-regex@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-regexp-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/a34d89a2b75fb78e66d97c3dc90d4877f7e31f43316b52176f95a5dee20e9bb56ecf158eafc42a001676ddf7b393d9e67650bad6b32f5405780f25fb83cd68e3 + checksum: 10/1ade0672ae5bbbf2ec1ea0a8de1b5d804ae414283215620097ab21cf7f05dae8916f5b0548a18c6f080ec17135018f5edd2d38f8fa9ca052af570cab5c712786 languageName: node linkType: hard -"@babel/plugin-transform-unicode-sets-regex@npm:^7.25.7, @babel/plugin-transform-unicode-sets-regex@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/plugin-transform-unicode-sets-regex@npm:7.28.6" +"@babel/plugin-transform-unicode-sets-regex@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-unicode-sets-regex@npm:7.29.7" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-create-regexp-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/423971fe2eef9d18782b1c30f5f42613ee510e5b9c08760c5538a0997b36c34495acce261e0e37a27831f81330359230bd1f33c2e1822de70241002b45b7d68e - languageName: node - linkType: hard - -"@babel/preset-env@npm:7.25.7": - version: 7.25.7 - resolution: "@babel/preset-env@npm:7.25.7" - dependencies: - "@babel/compat-data": "npm:^7.25.7" - "@babel/helper-compilation-targets": "npm:^7.25.7" - "@babel/helper-plugin-utils": "npm:^7.25.7" - "@babel/helper-validator-option": "npm:^7.25.7" - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "npm:^7.25.7" - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "npm:^7.25.7" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "npm:^7.25.7" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "npm:^7.25.7" - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "npm:^7.25.7" - "@babel/plugin-proposal-private-property-in-object": "npm:7.21.0-placeholder-for-preset-env.2" - "@babel/plugin-syntax-async-generators": "npm:^7.8.4" - "@babel/plugin-syntax-class-properties": "npm:^7.12.13" - "@babel/plugin-syntax-class-static-block": "npm:^7.14.5" - "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" - "@babel/plugin-syntax-export-namespace-from": "npm:^7.8.3" - "@babel/plugin-syntax-import-assertions": "npm:^7.25.7" - "@babel/plugin-syntax-import-attributes": "npm:^7.25.7" - "@babel/plugin-syntax-import-meta": "npm:^7.10.4" - "@babel/plugin-syntax-json-strings": "npm:^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators": "npm:^7.10.4" - "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" - "@babel/plugin-syntax-numeric-separator": "npm:^7.10.4" - "@babel/plugin-syntax-object-rest-spread": "npm:^7.8.3" - "@babel/plugin-syntax-optional-catch-binding": "npm:^7.8.3" - "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" - "@babel/plugin-syntax-private-property-in-object": "npm:^7.14.5" - "@babel/plugin-syntax-top-level-await": "npm:^7.14.5" - "@babel/plugin-syntax-unicode-sets-regex": "npm:^7.18.6" - "@babel/plugin-transform-arrow-functions": "npm:^7.25.7" - "@babel/plugin-transform-async-generator-functions": "npm:^7.25.7" - "@babel/plugin-transform-async-to-generator": "npm:^7.25.7" - "@babel/plugin-transform-block-scoped-functions": "npm:^7.25.7" - "@babel/plugin-transform-block-scoping": "npm:^7.25.7" - "@babel/plugin-transform-class-properties": "npm:^7.25.7" - "@babel/plugin-transform-class-static-block": "npm:^7.25.7" - "@babel/plugin-transform-classes": "npm:^7.25.7" - "@babel/plugin-transform-computed-properties": "npm:^7.25.7" - "@babel/plugin-transform-destructuring": "npm:^7.25.7" - "@babel/plugin-transform-dotall-regex": "npm:^7.25.7" - "@babel/plugin-transform-duplicate-keys": "npm:^7.25.7" - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "npm:^7.25.7" - "@babel/plugin-transform-dynamic-import": "npm:^7.25.7" - "@babel/plugin-transform-exponentiation-operator": "npm:^7.25.7" - "@babel/plugin-transform-export-namespace-from": "npm:^7.25.7" - "@babel/plugin-transform-for-of": "npm:^7.25.7" - "@babel/plugin-transform-function-name": "npm:^7.25.7" - "@babel/plugin-transform-json-strings": "npm:^7.25.7" - "@babel/plugin-transform-literals": "npm:^7.25.7" - "@babel/plugin-transform-logical-assignment-operators": "npm:^7.25.7" - "@babel/plugin-transform-member-expression-literals": "npm:^7.25.7" - "@babel/plugin-transform-modules-amd": "npm:^7.25.7" - "@babel/plugin-transform-modules-commonjs": "npm:^7.25.7" - "@babel/plugin-transform-modules-systemjs": "npm:^7.25.7" - "@babel/plugin-transform-modules-umd": "npm:^7.25.7" - "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.25.7" - "@babel/plugin-transform-new-target": "npm:^7.25.7" - "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.25.7" - "@babel/plugin-transform-numeric-separator": "npm:^7.25.7" - "@babel/plugin-transform-object-rest-spread": "npm:^7.25.7" - "@babel/plugin-transform-object-super": "npm:^7.25.7" - "@babel/plugin-transform-optional-catch-binding": "npm:^7.25.7" - "@babel/plugin-transform-optional-chaining": "npm:^7.25.7" - "@babel/plugin-transform-parameters": "npm:^7.25.7" - "@babel/plugin-transform-private-methods": "npm:^7.25.7" - "@babel/plugin-transform-private-property-in-object": "npm:^7.25.7" - "@babel/plugin-transform-property-literals": "npm:^7.25.7" - "@babel/plugin-transform-regenerator": "npm:^7.25.7" - "@babel/plugin-transform-reserved-words": "npm:^7.25.7" - "@babel/plugin-transform-shorthand-properties": "npm:^7.25.7" - "@babel/plugin-transform-spread": "npm:^7.25.7" - "@babel/plugin-transform-sticky-regex": "npm:^7.25.7" - "@babel/plugin-transform-template-literals": "npm:^7.25.7" - "@babel/plugin-transform-typeof-symbol": "npm:^7.25.7" - "@babel/plugin-transform-unicode-escapes": "npm:^7.25.7" - "@babel/plugin-transform-unicode-property-regex": "npm:^7.25.7" - "@babel/plugin-transform-unicode-regex": "npm:^7.25.7" - "@babel/plugin-transform-unicode-sets-regex": "npm:^7.25.7" - "@babel/preset-modules": "npm:0.1.6-no-external-plugins" - babel-plugin-polyfill-corejs2: "npm:^0.4.10" - babel-plugin-polyfill-corejs3: "npm:^0.10.6" - babel-plugin-polyfill-regenerator: "npm:^0.6.1" - core-js-compat: "npm:^3.38.1" - semver: "npm:^6.3.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/2d09aa2cb765a9a1c8a7566e22948e69d69de6b9b763322790bd72ec0bd5bcadf7b02abbc6ad1bab917e1f6d60b1a7f4606778d88cc7d7e1388cc52d292db97d + checksum: 10/680c22e2a63bffbf6798b0c2f599b06beb7ea4d29ee37a56c9192014143d396c479b12783d9c343e107fa491aeeb65b1ca774fd76825ffb306eef7fb5e7b4b2c languageName: node linkType: hard -"@babel/preset-env@npm:^7.29.5": - version: 7.29.5 - resolution: "@babel/preset-env@npm:7.29.5" +"@babel/preset-env@npm:^7.25.7, @babel/preset-env@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/preset-env@npm:7.29.7" dependencies: - "@babel/compat-data": "npm:^7.29.3" - "@babel/helper-compilation-targets": "npm:^7.28.6" - "@babel/helper-plugin-utils": "npm:^7.28.6" - "@babel/helper-validator-option": "npm:^7.27.1" - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "npm:^7.28.5" - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "npm:^7.27.1" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "npm:^7.27.1" - "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "npm:^7.29.3" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "npm:^7.27.1" - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "npm:^7.28.6" + "@babel/compat-data": "npm:^7.29.7" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "npm:^7.29.7" + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "npm:^7.29.7" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "npm:^7.29.7" + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "npm:^7.29.7" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "npm:^7.29.7" + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "npm:^7.29.7" "@babel/plugin-proposal-private-property-in-object": "npm:7.21.0-placeholder-for-preset-env.2" - "@babel/plugin-syntax-import-assertions": "npm:^7.28.6" - "@babel/plugin-syntax-import-attributes": "npm:^7.28.6" + "@babel/plugin-syntax-import-assertions": "npm:^7.29.7" + "@babel/plugin-syntax-import-attributes": "npm:^7.29.7" "@babel/plugin-syntax-unicode-sets-regex": "npm:^7.18.6" - "@babel/plugin-transform-arrow-functions": "npm:^7.27.1" - "@babel/plugin-transform-async-generator-functions": "npm:^7.29.0" - "@babel/plugin-transform-async-to-generator": "npm:^7.28.6" - "@babel/plugin-transform-block-scoped-functions": "npm:^7.27.1" - "@babel/plugin-transform-block-scoping": "npm:^7.28.6" - "@babel/plugin-transform-class-properties": "npm:^7.28.6" - "@babel/plugin-transform-class-static-block": "npm:^7.28.6" - "@babel/plugin-transform-classes": "npm:^7.28.6" - "@babel/plugin-transform-computed-properties": "npm:^7.28.6" - "@babel/plugin-transform-destructuring": "npm:^7.28.5" - "@babel/plugin-transform-dotall-regex": "npm:^7.28.6" - "@babel/plugin-transform-duplicate-keys": "npm:^7.27.1" - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "npm:^7.29.0" - "@babel/plugin-transform-dynamic-import": "npm:^7.27.1" - "@babel/plugin-transform-explicit-resource-management": "npm:^7.28.6" - "@babel/plugin-transform-exponentiation-operator": "npm:^7.28.6" - "@babel/plugin-transform-export-namespace-from": "npm:^7.27.1" - "@babel/plugin-transform-for-of": "npm:^7.27.1" - "@babel/plugin-transform-function-name": "npm:^7.27.1" - "@babel/plugin-transform-json-strings": "npm:^7.28.6" - "@babel/plugin-transform-literals": "npm:^7.27.1" - "@babel/plugin-transform-logical-assignment-operators": "npm:^7.28.6" - "@babel/plugin-transform-member-expression-literals": "npm:^7.27.1" - "@babel/plugin-transform-modules-amd": "npm:^7.27.1" - "@babel/plugin-transform-modules-commonjs": "npm:^7.28.6" - "@babel/plugin-transform-modules-systemjs": "npm:^7.29.4" - "@babel/plugin-transform-modules-umd": "npm:^7.27.1" - "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.29.0" - "@babel/plugin-transform-new-target": "npm:^7.27.1" - "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.28.6" - "@babel/plugin-transform-numeric-separator": "npm:^7.28.6" - "@babel/plugin-transform-object-rest-spread": "npm:^7.28.6" - "@babel/plugin-transform-object-super": "npm:^7.27.1" - "@babel/plugin-transform-optional-catch-binding": "npm:^7.28.6" - "@babel/plugin-transform-optional-chaining": "npm:^7.28.6" - "@babel/plugin-transform-parameters": "npm:^7.27.7" - "@babel/plugin-transform-private-methods": "npm:^7.28.6" - "@babel/plugin-transform-private-property-in-object": "npm:^7.28.6" - "@babel/plugin-transform-property-literals": "npm:^7.27.1" - "@babel/plugin-transform-regenerator": "npm:^7.29.0" - "@babel/plugin-transform-regexp-modifiers": "npm:^7.28.6" - "@babel/plugin-transform-reserved-words": "npm:^7.27.1" - "@babel/plugin-transform-shorthand-properties": "npm:^7.27.1" - "@babel/plugin-transform-spread": "npm:^7.28.6" - "@babel/plugin-transform-sticky-regex": "npm:^7.27.1" - "@babel/plugin-transform-template-literals": "npm:^7.27.1" - "@babel/plugin-transform-typeof-symbol": "npm:^7.27.1" - "@babel/plugin-transform-unicode-escapes": "npm:^7.27.1" - "@babel/plugin-transform-unicode-property-regex": "npm:^7.28.6" - "@babel/plugin-transform-unicode-regex": "npm:^7.27.1" - "@babel/plugin-transform-unicode-sets-regex": "npm:^7.28.6" + "@babel/plugin-transform-arrow-functions": "npm:^7.29.7" + "@babel/plugin-transform-async-generator-functions": "npm:^7.29.7" + "@babel/plugin-transform-async-to-generator": "npm:^7.29.7" + "@babel/plugin-transform-block-scoped-functions": "npm:^7.29.7" + "@babel/plugin-transform-block-scoping": "npm:^7.29.7" + "@babel/plugin-transform-class-properties": "npm:^7.29.7" + "@babel/plugin-transform-class-static-block": "npm:^7.29.7" + "@babel/plugin-transform-classes": "npm:^7.29.7" + "@babel/plugin-transform-computed-properties": "npm:^7.29.7" + "@babel/plugin-transform-destructuring": "npm:^7.29.7" + "@babel/plugin-transform-dotall-regex": "npm:^7.29.7" + "@babel/plugin-transform-duplicate-keys": "npm:^7.29.7" + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "npm:^7.29.7" + "@babel/plugin-transform-dynamic-import": "npm:^7.29.7" + "@babel/plugin-transform-explicit-resource-management": "npm:^7.29.7" + "@babel/plugin-transform-exponentiation-operator": "npm:^7.29.7" + "@babel/plugin-transform-export-namespace-from": "npm:^7.29.7" + "@babel/plugin-transform-for-of": "npm:^7.29.7" + "@babel/plugin-transform-function-name": "npm:^7.29.7" + "@babel/plugin-transform-json-strings": "npm:^7.29.7" + "@babel/plugin-transform-literals": "npm:^7.29.7" + "@babel/plugin-transform-logical-assignment-operators": "npm:^7.29.7" + "@babel/plugin-transform-member-expression-literals": "npm:^7.29.7" + "@babel/plugin-transform-modules-amd": "npm:^7.29.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.29.7" + "@babel/plugin-transform-modules-systemjs": "npm:^7.29.7" + "@babel/plugin-transform-modules-umd": "npm:^7.29.7" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.29.7" + "@babel/plugin-transform-new-target": "npm:^7.29.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.29.7" + "@babel/plugin-transform-numeric-separator": "npm:^7.29.7" + "@babel/plugin-transform-object-rest-spread": "npm:^7.29.7" + "@babel/plugin-transform-object-super": "npm:^7.29.7" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.29.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.29.7" + "@babel/plugin-transform-parameters": "npm:^7.29.7" + "@babel/plugin-transform-private-methods": "npm:^7.29.7" + "@babel/plugin-transform-private-property-in-object": "npm:^7.29.7" + "@babel/plugin-transform-property-literals": "npm:^7.29.7" + "@babel/plugin-transform-regenerator": "npm:^7.29.7" + "@babel/plugin-transform-regexp-modifiers": "npm:^7.29.7" + "@babel/plugin-transform-reserved-words": "npm:^7.29.7" + "@babel/plugin-transform-shorthand-properties": "npm:^7.29.7" + "@babel/plugin-transform-spread": "npm:^7.29.7" + "@babel/plugin-transform-sticky-regex": "npm:^7.29.7" + "@babel/plugin-transform-template-literals": "npm:^7.29.7" + "@babel/plugin-transform-typeof-symbol": "npm:^7.29.7" + "@babel/plugin-transform-unicode-escapes": "npm:^7.29.7" + "@babel/plugin-transform-unicode-property-regex": "npm:^7.29.7" + "@babel/plugin-transform-unicode-regex": "npm:^7.29.7" + "@babel/plugin-transform-unicode-sets-regex": "npm:^7.29.7" "@babel/preset-modules": "npm:0.1.6-no-external-plugins" babel-plugin-polyfill-corejs2: "npm:^0.4.15" babel-plugin-polyfill-corejs3: "npm:^0.14.0" @@ -1471,7 +1312,7 @@ __metadata: semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/2e54630764b6650d81df5ce5a47fa260acd3695dc95a6b989b713bf6c0713fb320e3ae3f76f0c636bfda058ee5c582a3de7f5d58d691c68ca566129c7d3d0f0a + checksum: 10/08df2b7dc47108cdd66f807bc6390caa20e54cc4780a320cf5a8049c37af49f3c03889e10bc7911a51bfd854360e120b9443fa039b51356a805784215b81b451 languageName: node linkType: hard @@ -1488,54 +1329,54 @@ __metadata: languageName: node linkType: hard -"@babel/preset-typescript@npm:7.25.7": - version: 7.25.7 - resolution: "@babel/preset-typescript@npm:7.25.7" +"@babel/preset-typescript@npm:^7.25.7": + version: 7.29.7 + resolution: "@babel/preset-typescript@npm:7.29.7" dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.7" - "@babel/helper-validator-option": "npm:^7.25.7" - "@babel/plugin-syntax-jsx": "npm:^7.25.7" - "@babel/plugin-transform-modules-commonjs": "npm:^7.25.7" - "@babel/plugin-transform-typescript": "npm:^7.25.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" + "@babel/plugin-syntax-jsx": "npm:^7.29.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.29.7" + "@babel/plugin-transform-typescript": "npm:^7.29.7" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/cf6501971f696800096f3b3aef4f7e2c774210b4204bb3a076b2f253970d6649c28003de3afc620b7c7ad67fb346083819c89bae2c644f59995ddb64d6003541 + checksum: 10/487c5b5ee80afd656a8e6254c0638559fa4cd8d11b8d9ef9328cd5c403b8dfd1003690246910681de0f12e879d38fdf91aa2592f51ab9761e5197a3b36508919 languageName: node linkType: hard -"@babel/template@npm:^7.25.7, @babel/template@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/template@npm:7.28.6" +"@babel/template@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/template@npm:7.29.7" dependencies: - "@babel/code-frame": "npm:^7.28.6" - "@babel/parser": "npm:^7.28.6" - "@babel/types": "npm:^7.28.6" - checksum: 10/0ad6e32bf1e7e31bf6b52c20d15391f541ddd645cbd488a77fe537a15b280ee91acd3a777062c52e03eedbc2e1f41548791f6a3697c02476ec5daf49faa38533 + "@babel/code-frame": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10/da92f7a5b61e05d2fb3934a44f18cec6006ee3c595116c17a3b44cb9756ecd43205c7360dbfa326fa8f4d00aaeb9e777342a881070d11c2305e9c694bc3ca6ff languageName: node linkType: hard -"@babel/traverse@npm:^7.25.7, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.5, @babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/traverse@npm:7.29.0" +"@babel/traverse@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/traverse@npm:7.29.7" dependencies: - "@babel/code-frame": "npm:^7.29.0" - "@babel/generator": "npm:^7.29.0" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.29.0" - "@babel/template": "npm:^7.28.6" - "@babel/types": "npm:^7.29.0" + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.7" + "@babel/helper-globals": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" debug: "npm:^4.3.1" - checksum: 10/3a0d0438f1ba9fed4fbe1706ea598a865f9af655a16ca9517ab57bda526e224569ca1b980b473fb68feea5e08deafbbf2cf9febb941f92f2d2533310c3fc4abc + checksum: 10/ce24086a7dd8c408cbdb159437d3c8e02464a6d32b320d884fa742e2c5a3344b9342a923c7a371fc1789b4d82a59972a7008b5d8bbc1bc0c5ae42a39b28dc7f6 languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.7, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.4.4": - version: 7.29.0 - resolution: "@babel/types@npm:7.29.0" +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.4.4": + version: 7.29.7 + resolution: "@babel/types@npm:7.29.7" dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.28.5" - checksum: 10/bfc2b211210f3894dcd7e6a33b2d1c32c93495dc1e36b547376aa33441abe551ab4bc1640d4154ee2acd8e46d3bbc925c7224caae02fcaf0e6a771e97fccc661 + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10/bd4f5635db1057bd0abeebf93eb3ae38399e152271cea8dce8288350f0afa13ed3e2db2e16e22bd3303068890eec18965a83420539afbe0dde31432b4cf9636d languageName: node linkType: hard @@ -1547,9 +1388,9 @@ __metadata: linkType: hard "@colordx/core@npm:^5.4.3": - version: 5.4.3 - resolution: "@colordx/core@npm:5.4.3" - checksum: 10/3a26146cc3ce9e1ab51d428142e7c307cd023c7660b6d604297122661cfc57ebe0c2cfcfd156ccef1a47a9d153da640017827afd67e223dd5b6a24958a908dc0 + version: 5.5.0 + resolution: "@colordx/core@npm:5.5.0" + checksum: 10/66c6e3b9ee7811a7cb9ffb044bd4b2920fa23aa581386396d0f928944eb0323cc47cba7b79c34bf4bb0c9805574a600187059803915aefbc8022f90f2ca6fff4 languageName: node linkType: hard @@ -1599,7 +1440,7 @@ __metadata: languageName: node linkType: hard -"@discoveryjs/json-ext@npm:^1.0.0": +"@discoveryjs/json-ext@npm:^1.1.0": version: 1.1.0 resolution: "@discoveryjs/json-ext@npm:1.1.0" checksum: 10/c941e41fcfa0d40bfd0b12e5cd5995b609328adb5fa881b1e8bebcae57784d505ccb92ae5d470055336802197a664d3856decccfd09a59c8feef2bf214591ff5 @@ -1647,15 +1488,15 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-plugin-eslint-comments@npm:^4.4.0": - version: 4.7.1 - resolution: "@eslint-community/eslint-plugin-eslint-comments@npm:4.7.1" +"@eslint-community/eslint-plugin-eslint-comments@npm:^4.7.0": + version: 4.7.2 + resolution: "@eslint-community/eslint-plugin-eslint-comments@npm:4.7.2" dependencies: escape-string-regexp: "npm:^4.0.0" ignore: "npm:^7.0.5" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - checksum: 10/98a77e19a96d75adb798c318d78fb26420c52fb462fec507b7d45f747cffc21021b66b14da6c11c0121fbebedfda2d45ff38e4d6d565ed9e1be40caf880c6191 + checksum: 10/b05246ba8303000024f25849bea015034f20c43e39260be6b84ad4371d03ccdda2dd3ed3630bdc46fd244b1e09a8a253411f3dfe24045b0de71c1aa8fa8b99ad languageName: node linkType: hard @@ -1727,13 +1568,13 @@ __metadata: languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.7.1": - version: 0.7.1 - resolution: "@eslint/plugin-kit@npm:0.7.1" +"@eslint/plugin-kit@npm:^0.7.2": + version: 0.7.2 + resolution: "@eslint/plugin-kit@npm:0.7.2" dependencies: "@eslint/core": "npm:^1.2.1" levn: "npm:^0.4.1" - checksum: 10/8f923f4cdadadd215e0c2028e6a53101bb148a7780cdb4dc8cd69b0c77fc88496742e87e0605b12905ff715e2c7ad6cbd2d92c5653cdbf91cca1e229b5022c1f + checksum: 10/ef9fc6f8ca28e132d4c81cfbaa92274800d1d73bb9d6ef2124613dd39b7f09e3592deb64bad10b183bff78db5465d4b100f522d994c8550424526b9ac4a072b0 languageName: node linkType: hard @@ -1778,15 +1619,13 @@ __metadata: languageName: node linkType: hard -"@inrupt/jest-jsdom-polyfills@npm:^4.1.1": - version: 4.1.1 - resolution: "@inrupt/jest-jsdom-polyfills@npm:4.1.1" +"@inrupt/jest-jsdom-polyfills@npm:^4.1.4": + version: 4.1.4 + resolution: "@inrupt/jest-jsdom-polyfills@npm:4.1.4" dependencies: - "@peculiar/webcrypto": "npm:^1.6.0" - "@web-std/blob": "npm:^3.0.5" - "@web-std/file": "npm:^3.0.3" - undici: "npm:^8.2.0" - checksum: 10/4e63a397dbeb3f4bae9d4c94abed4c151f1ea2633e801a511404d0b17db59709650051415823d0f3337185d89611b36cc5dc6f96b7f5c056da846d41848a8f33 + "@peculiar/webcrypto": "npm:^1.7.1" + undici: "npm:^8.5.0" + checksum: 10/f8ad282a52f4d66b8c64c376e98cdc5a14e448ce077dbbe80fe5ab232ea68430b082b817b8eb5e07632d6285a6ba27ee8e90a5e115ae022a7142d247a39c47a7 languageName: node linkType: hard @@ -2174,14 +2013,14 @@ __metadata: linkType: hard "@napi-rs/wasm-runtime@npm:^1.1.4": - version: 1.1.4 - resolution: "@napi-rs/wasm-runtime@npm:1.1.4" + version: 1.1.6 + resolution: "@napi-rs/wasm-runtime@npm:1.1.6" dependencies: - "@tybys/wasm-util": "npm:^0.10.1" + "@tybys/wasm-util": "npm:^0.10.3" peerDependencies: "@emnapi/core": ^1.7.1 "@emnapi/runtime": ^1.7.1 - checksum: 10/1db3dc7eeb981306b09360487bd8ce4dfa5588d273bd8ea9f07dccca1b4ade57b675414180fc9bb66966c6c50b17208b0263194993e2f7f92cc7af28bda4d1af + checksum: 10/3e43425df17547d9d58ab69cce8e6cef38a062eccec4d2def5fc9e10e81cd19ae228b3ab9be4b149b57078d33913687511312d2414089877759b7db1f43625ba languageName: node linkType: hard @@ -2195,13 +2034,13 @@ __metadata: linkType: hard "@peculiar/asn1-schema@npm:^2.7.0": - version: 2.7.0 - resolution: "@peculiar/asn1-schema@npm:2.7.0" + version: 2.8.0 + resolution: "@peculiar/asn1-schema@npm:2.8.0" dependencies: "@peculiar/utils": "npm:^2.0.2" - asn1js: "npm:^3.0.6" + asn1js: "npm:^3.0.10" tslib: "npm:^2.8.1" - checksum: 10/2b18ee2f3de2b68a36b964721e5101f589d6a1db765c450ce5a929829bfc8c0819e0b128145f65639952b257b9bdaa6ce7d1a54cd93c7bf6e694fed4c36d6c98 + checksum: 10/2964a77c290747cce1f989a55c44a205f34ba00a60e5a7a0fcc2e23d924c7b1f3d33bcf9a8aac7cf4356fed5773f03c1f10b0c6dbbb8c3edbc6217f95282cf15 languageName: node linkType: hard @@ -2223,7 +2062,7 @@ __metadata: languageName: node linkType: hard -"@peculiar/webcrypto@npm:^1.6.0": +"@peculiar/webcrypto@npm:^1.7.1": version: 1.7.1 resolution: "@peculiar/webcrypto@npm:1.7.1" dependencies: @@ -2243,10 +2082,10 @@ __metadata: languageName: node linkType: hard -"@pkgr/core@npm:^0.2.9": - version: 0.2.9 - resolution: "@pkgr/core@npm:0.2.9" - checksum: 10/bb2fb86977d63f836f8f5b09015d74e6af6488f7a411dcd2bfdca79d76b5a681a9112f41c45bdf88a9069f049718efc6f3900d7f1de66a2ec966068308ae517f +"@pkgr/core@npm:^0.3.6": + version: 0.3.6 + resolution: "@pkgr/core@npm:0.3.6" + checksum: 10/29082aa13d36f13fc41cdc64cb1feb36b630de0d6ebde84e2ff68e3d7a7f1dce4462cca91f76176c46e50bbca6b1e7f1fd9cf907af12d5d70da83bc981ca4ccf languageName: node linkType: hard @@ -2282,12 +2121,52 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.1": - version: 0.10.2 - resolution: "@tybys/wasm-util@npm:0.10.2" +"@tannin/compile@npm:^1.1.0": + version: 1.1.0 + resolution: "@tannin/compile@npm:1.1.0" + dependencies: + "@tannin/evaluate": "npm:^1.2.0" + "@tannin/postfix": "npm:^1.1.0" + checksum: 10/5b85a37f25b0486e09a4f14a7099f4ea495177d96f57f1d637a9f63f74360593bb5d9faf27c44eb078cc67a8c8ce53d2300d769b3d10dd39be1c896d757ad1d4 + languageName: node + linkType: hard + +"@tannin/evaluate@npm:^1.2.0": + version: 1.2.0 + resolution: "@tannin/evaluate@npm:1.2.0" + checksum: 10/8db22c3be4e3adfdd2314f8bb7ce0fff1eac3f58cbf3a374fd28815592a6abf2262f979194b2b888d36b41a56930fb070eae5486a81d54bc70665eb2248e0871 + languageName: node + linkType: hard + +"@tannin/plural-forms@npm:^1.1.0": + version: 1.1.0 + resolution: "@tannin/plural-forms@npm:1.1.0" + dependencies: + "@tannin/compile": "npm:^1.1.0" + checksum: 10/ae515980623f963d386c6e820d1b0473f9a6606d101d4af8da897c0309c1e7e7764544890f5c5f59792d0877fc05c0eb8721f1d9d4c3e1c5f00b1ae65b88ecdd + languageName: node + linkType: hard + +"@tannin/postfix@npm:^1.1.0": + version: 1.1.0 + resolution: "@tannin/postfix@npm:1.1.0" + checksum: 10/3fe7ee00a87e5ed649b535d31cf833bc209aef87716e0c78d6a5f4d5b54d8c299040ee3efa0d898782b7683b091ad9b3b0b8588834044cfd5f194b750cee164c + languageName: node + linkType: hard + +"@tannin/sprintf@npm:^1.3.2": + version: 1.3.3 + resolution: "@tannin/sprintf@npm:1.3.3" + checksum: 10/d202e561a69e45131bd1707c51f9ea5a5cdfffc30b3cb7ff5b6f110f10db660d5f3d961be8799c343b248ec95fd676348e56a2728bd1557e6c60fa7e7d2d4257 + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.10.3": + version: 0.10.3 + resolution: "@tybys/wasm-util@npm:0.10.3" dependencies: tslib: "npm:^2.4.0" - checksum: 10/d12f1dafe12d7a573c406b35ffef0038042b9cc9fbcc74d657267eb635499b956276afc05eebdbd81bea582e1c4c921421a1dd7243a93daaa8c8216b19395c23 + checksum: 10/6cf39f7a2926b1c8bc6fe3f9f03318a33dd6dae81bdbd059983f9c6ee22d10a827f12564d648c05a2d4926e03c86cbe2799fb20609ee65e9efc39603039b4765 languageName: node linkType: hard @@ -2396,12 +2275,19 @@ __metadata: languageName: node linkType: hard +"@types/mousetrap@npm:^1.6.8": + version: 1.6.15 + resolution: "@types/mousetrap@npm:1.6.15" + checksum: 10/58ec552218108f8e5f0d40ee81579aafbdb00c3af0ef4b6d3fb193a8a11a2d37aa84c6e20524120b7f218c2a87dac24f426ee3b23e1269d42f8145a1a37c9eca + languageName: node + linkType: hard + "@types/node@npm:*": - version: 25.9.1 - resolution: "@types/node@npm:25.9.1" + version: 26.0.1 + resolution: "@types/node@npm:26.0.1" dependencies: - undici-types: "npm:>=7.24.0 <7.24.7" - checksum: 10/8a1ccf60f0c0ca856d3324a690ee35776f26dfc1d51c3763aecdf246a3246a7971a0156bf6eb3239aa22dfa940eb361d048212f5c3204264d31ef4c41d17416a + undici-types: "npm:~8.3.0" + checksum: 10/c312909c89c4f52291084d5b8b67c119f3c3adce9bdd5e57da74cb4d05059021b2b2b6b5eb52ccb086d66397b5793864e4d48b17d608ab42c27cfc193d337823 languageName: node linkType: hard @@ -2429,12 +2315,12 @@ __metadata: linkType: hard "@types/react@npm:^18.3.27": - version: 18.3.29 - resolution: "@types/react@npm:18.3.29" + version: 18.3.31 + resolution: "@types/react@npm:18.3.31" dependencies: "@types/prop-types": "npm:*" csstype: "npm:^3.2.2" - checksum: 10/1630416dbca8097bc56bb2f7e51208a7d696ed704c58bb3f0470eb982852f1af6d95d5c658eec51d7a5adae6587679d7d81b531b676dfcb405164e32feee6b4b + checksum: 10/0a5d73b1ba3ce73273b28cc50da78d520b1a8d4253075820dc5cf6c9e286ff6cb339b56bd613d05885fec214fbecd8137bddcbdb475612fdcc46e061d88f00fc languageName: node linkType: hard @@ -2468,105 +2354,105 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/eslint-plugin@npm:8.59.4" +"@typescript-eslint/eslint-plugin@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.62.0" dependencies: "@eslint-community/regexpp": "npm:^4.12.2" - "@typescript-eslint/scope-manager": "npm:8.59.4" - "@typescript-eslint/type-utils": "npm:8.59.4" - "@typescript-eslint/utils": "npm:8.59.4" - "@typescript-eslint/visitor-keys": "npm:8.59.4" + "@typescript-eslint/scope-manager": "npm:8.62.0" + "@typescript-eslint/type-utils": "npm:8.62.0" + "@typescript-eslint/utils": "npm:8.62.0" + "@typescript-eslint/visitor-keys": "npm:8.62.0" ignore: "npm:^7.0.5" natural-compare: "npm:^1.4.0" ts-api-utils: "npm:^2.5.0" peerDependencies: - "@typescript-eslint/parser": ^8.59.4 + "@typescript-eslint/parser": ^8.62.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10/75348fdfc5a69bf9837c7ee3a5997bd7e9176eee00682735e46e199e5b67e4748440dd23c65677860a6caaa1c05b67cfafa57a8623da65a17c2832e322f6b7c4 + checksum: 10/a093962c84e49d7524078a97c3ecfdedfaa217a6f68047d3eedb29677425210acfacaa2fe88f447e9662063979f31c8268e4568caca038df09deee9f06124d7f languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/parser@npm:8.59.4" +"@typescript-eslint/parser@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/parser@npm:8.62.0" dependencies: - "@typescript-eslint/scope-manager": "npm:8.59.4" - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/typescript-estree": "npm:8.59.4" - "@typescript-eslint/visitor-keys": "npm:8.59.4" + "@typescript-eslint/scope-manager": "npm:8.62.0" + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/typescript-estree": "npm:8.62.0" + "@typescript-eslint/visitor-keys": "npm:8.62.0" debug: "npm:^4.4.3" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10/6d07a206cac1aa168f9973ac41c3c3a05d7d391a5fa720b4adab5f7c278b62806269ed4e48fcb5706d87213d64bb25f040c9df4824162f8eabb180bee9a2fc23 + checksum: 10/d0abbf12080532f6460af186098fab15f1f3695ee4817f96209ecfb00ef7ec89ec476051bde35a396217c8e37d5e441f3814807eb082e11904a0a1dc4b6d3b14 languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/project-service@npm:8.59.4" +"@typescript-eslint/project-service@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/project-service@npm:8.62.0" dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.59.4" - "@typescript-eslint/types": "npm:^8.59.4" + "@typescript-eslint/tsconfig-utils": "npm:^8.62.0" + "@typescript-eslint/types": "npm:^8.62.0" debug: "npm:^4.4.3" peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 10/abd5ac32f8792c9bdabdb510d7ba9f708c7760994f3d4a2c741ecf5baecccf7a111e9706eb46a28a3678998671cdc9912f4e8f2423e091bbaea2b6836e07c14d + checksum: 10/e296f3aaaf7b4fc56e6410420a98a995f59bf45187445c9ad94d76de557a47071558869414c8ec179dfefce4f65ef8c15fcda7db653ed8fb95ff25b8119f9bb1 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/scope-manager@npm:8.59.4" +"@typescript-eslint/scope-manager@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/scope-manager@npm:8.62.0" dependencies: - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/visitor-keys": "npm:8.59.4" - checksum: 10/945a3498a61e27109ef78fa41fbf6ebca76dcc75c4595019bbc131892658b8204e9872239d4a1347fec2b3ca3c3e26460edabf7941cf727e9a35105d6f383c5d + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/visitor-keys": "npm:8.62.0" + checksum: 10/6477062eb056986c9f94b35761c0b67bb9995798ba94c5d2bcb01932e525604715ce62e816468b2c80a8f05daa33b3339ea40646a31f733ea9840cee1dd3e82d languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.59.4, @typescript-eslint/tsconfig-utils@npm:^8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.59.4" +"@typescript-eslint/tsconfig-utils@npm:8.62.0, @typescript-eslint/tsconfig-utils@npm:^8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.62.0" peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 10/c74a356e67f17aa2b61bffbe145548a5b37e6db34077922fb74c5f07393cd3a1ef28742f9a3e3acc53ac0e95c8af6e959bdf44988e036d634492d79a9dd219b1 + checksum: 10/578f486df8eb2d2ec3939afc37102b89521d531d409d76e30a8ac3e9f48a3ae410e19e40c2aba3810f28391925a35ed391204ff786cc230542de82817efafda0 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/type-utils@npm:8.59.4" +"@typescript-eslint/type-utils@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/type-utils@npm:8.62.0" dependencies: - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/typescript-estree": "npm:8.59.4" - "@typescript-eslint/utils": "npm:8.59.4" + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/typescript-estree": "npm:8.62.0" + "@typescript-eslint/utils": "npm:8.62.0" debug: "npm:^4.4.3" ts-api-utils: "npm:^2.5.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10/6c513a9ab5e9da3fafe165b64da73c984696e3f583ae4a5c7d188f16f9425ec488ed4aff39d8b419a742e92078fb1cb4384dce9e1efa7bb193e3aa0cdcff84c8 + checksum: 10/e1627d2bd792cf856c36db4f4e9c89e3111ad9bf81fc7489e957d26d36f89ab00226eee1838ee499947a898e37c1f30338bca4fa05ca437154c1813d54831b7e languageName: node linkType: hard -"@typescript-eslint/types@npm:8.59.4, @typescript-eslint/types@npm:^8.11.0, @typescript-eslint/types@npm:^8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/types@npm:8.59.4" - checksum: 10/43e7ab668f1649dee76829a5b1eb0807b7576b39aee5b23fe10936d99536cba350ec1e377c2ea2242971435d460e28f3d3d4307357ac9f8184403573ece85ca6 +"@typescript-eslint/types@npm:8.62.0, @typescript-eslint/types@npm:^8.11.0, @typescript-eslint/types@npm:^8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/types@npm:8.62.0" + checksum: 10/459f5834dedbb73fc80c8eb92de693faef0f0f341d3b4d65426dbf43640f98a50104f6e15108808aed2c3c66d518db15a648c72c40831f498d36bfb62be564cb languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/typescript-estree@npm:8.59.4" +"@typescript-eslint/typescript-estree@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.62.0" dependencies: - "@typescript-eslint/project-service": "npm:8.59.4" - "@typescript-eslint/tsconfig-utils": "npm:8.59.4" - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/visitor-keys": "npm:8.59.4" + "@typescript-eslint/project-service": "npm:8.62.0" + "@typescript-eslint/tsconfig-utils": "npm:8.62.0" + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/visitor-keys": "npm:8.62.0" debug: "npm:^4.4.3" minimatch: "npm:^10.2.2" semver: "npm:^7.7.3" @@ -2574,39 +2460,39 @@ __metadata: ts-api-utils: "npm:^2.5.0" peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 10/6ca75d11bd5e1f44ddc0cd8ef4f4833257bcb1d48b82485b55008cd6761f4dba0671754098ce5b2910432ba05f5f9247c34942f3e8de2857cb5ab0d25b866876 + checksum: 10/c3ae8e13671957e4a1c4acfc861b40e1545a9d32fe9d5cc851992186314f5a1dbe780cecc8f16b8448a1350f4c11648572352b5d04b77bb62e8dac3e6f3a2e04 languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.59.4, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0": - version: 8.59.4 - resolution: "@typescript-eslint/utils@npm:8.59.4" +"@typescript-eslint/utils@npm:8.62.0, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0": + version: 8.62.0 + resolution: "@typescript-eslint/utils@npm:8.62.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.9.1" - "@typescript-eslint/scope-manager": "npm:8.59.4" - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/typescript-estree": "npm:8.59.4" + "@typescript-eslint/scope-manager": "npm:8.62.0" + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/typescript-estree": "npm:8.62.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10/90d779f1af27e34df7e44f93135c08908e98c4b830329be1220d5edd0e024272f6d5a021506dae7fe6446a8bf05ccd0a5e7fb3ed36399dcf4f52959bb302c62d + checksum: 10/95fed9feb823106f09b517637b0cf00e39fdc3537d05023f84710f23d00457898d8f1c68a69115d624a686411136b5cfdd7b84b657d6b51aea410cf2eb7fde7a languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/visitor-keys@npm:8.59.4" +"@typescript-eslint/visitor-keys@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.62.0" dependencies: - "@typescript-eslint/types": "npm:8.59.4" + "@typescript-eslint/types": "npm:8.62.0" eslint-visitor-keys: "npm:^5.0.0" - checksum: 10/b620ba3a702817cc1cff6ac654ef5538be0b5076a478546c9bf42ece25bc3b08a33a4d003b9b42d10bd1041ea246f108863aceca0683b4389cf18d55dcdbee10 + checksum: 10/63d66db628befb1d160c02f3fe3b00b7c7ffc47a477335591affde2108e913a72d4a327bdd15d91f5784c3c3624e9b347e9351754390a61ca182bb7e1788d350 languageName: node linkType: hard "@ungap/structured-clone@npm:^1.3.0": - version: 1.3.1 - resolution: "@ungap/structured-clone@npm:1.3.1" - checksum: 10/64df206f50aef71c176f9059c1b29e1694821419c6728c446ecf39c80a811eeef156668bf51421b676494a12fd0129ccf09a44f0c641f13c27f50d5f0db6de4e + version: 1.3.2 + resolution: "@ungap/structured-clone@npm:1.3.2" + checksum: 10/e31518037b406845124fe039aaa859ea8dad6ee892fe2140af8300e09f5dabc304534ec84ff522e5420f9665fe6160492bb008d205fbe2e288ba28f862f912c9 languageName: node linkType: hard @@ -2768,34 +2654,6 @@ __metadata: languageName: node linkType: hard -"@web-std/blob@npm:^3.0.3, @web-std/blob@npm:^3.0.5": - version: 3.0.5 - resolution: "@web-std/blob@npm:3.0.5" - dependencies: - "@web-std/stream": "npm:1.0.0" - web-encoding: "npm:1.1.5" - checksum: 10/7a28335287296bcbeeb573b194952a2d7052fd57304ca74399bbf014cfa3e11cfa6404d4720f845e3b343cd9164d4dfb226fc7c60b2fee4e8f0922b7ff123d0c - languageName: node - linkType: hard - -"@web-std/file@npm:^3.0.3": - version: 3.0.3 - resolution: "@web-std/file@npm:3.0.3" - dependencies: - "@web-std/blob": "npm:^3.0.3" - checksum: 10/0f9e86288c34cb1771f291075dee8e57cded07c55694c0b94ac1e0fadca87a7db7974b1ae1fe86caaefa03e703d3e86722ac654e60925215a5a69e1a0c987d97 - languageName: node - linkType: hard - -"@web-std/stream@npm:1.0.0": - version: 1.0.0 - resolution: "@web-std/stream@npm:1.0.0" - dependencies: - web-streams-polyfill: "npm:^3.1.1" - checksum: 10/280b69f86ed9575f04543a189f13c6448d5c5ced781acc3e590e827e54db02dec3c836b71e2e2b3d537ddd4e4f840d3f6fbf110cd823a5879d8c40e9f3c57f73 - languageName: node - linkType: hard - "@webassemblyjs/ast@npm:1.14.1, @webassemblyjs/ast@npm:^1.14.1": version: 1.14.1 resolution: "@webassemblyjs/ast@npm:1.14.1" @@ -2947,78 +2805,123 @@ __metadata: languageName: node linkType: hard -"@wordpress/babel-preset-default@npm:^8.46.0": - version: 8.46.0 - resolution: "@wordpress/babel-preset-default@npm:8.46.0" +"@wordpress/babel-preset-default@npm:^8.49.0": + version: 8.49.0 + resolution: "@wordpress/babel-preset-default@npm:8.49.0" dependencies: - "@babel/core": "npm:7.25.7" - "@babel/plugin-syntax-import-attributes": "npm:7.26.0" - "@babel/plugin-transform-react-jsx": "npm:7.25.7" - "@babel/plugin-transform-runtime": "npm:7.25.7" - "@babel/preset-env": "npm:7.25.7" - "@babel/preset-typescript": "npm:7.25.7" - "@wordpress/browserslist-config": "npm:^6.46.0" - "@wordpress/warning": "npm:^3.46.0" + "@babel/core": "npm:^7.25.7" + "@babel/plugin-syntax-import-attributes": "npm:^7.26.0" + "@babel/plugin-transform-react-jsx": "npm:^7.25.7" + "@babel/plugin-transform-runtime": "npm:^7.25.7" + "@babel/preset-env": "npm:^7.25.7" + "@babel/preset-typescript": "npm:^7.25.7" + "@wordpress/browserslist-config": "npm:^6.49.0" + "@wordpress/warning": "npm:^3.49.0" browserslist: "npm:^4.21.10" core-js: "npm:^3.31.0" - react: "npm:^18.3.0" - checksum: 10/4034229059e875d595e5234d829a39541437c692e8a3949e08d582022db69f18490c7e4bef8e99992714ec37c424358f23b9f6118902b58ccd7dd6adfaf33ddf + react: "npm:^18.3.1" + checksum: 10/28baf4c1675090f9b9db15b51a24d6cfe01b0e154eee68c34ca28d1885c9227c75b2f70557c250429e19e1b17bf2a254af019f1ee805b4fa9ac4354e651adc1a + languageName: node + linkType: hard + +"@wordpress/browserslist-config@npm:^6.49.0": + version: 6.49.0 + resolution: "@wordpress/browserslist-config@npm:6.49.0" + checksum: 10/b62889b511116fd313f7e6476c895c7bee27409791f529172917d29facdf33f4bdeaeb5cf89274609d5b6ba36d0db4e1e84a5191d34ccc752ace66388927fb20 + languageName: node + linkType: hard + +"@wordpress/compose@npm:^8.2.0": + version: 8.2.0 + resolution: "@wordpress/compose@npm:8.2.0" + dependencies: + "@types/mousetrap": "npm:^1.6.8" + "@wordpress/deprecated": "npm:^4.49.0" + "@wordpress/dom": "npm:^4.49.0" + "@wordpress/element": "npm:^8.1.0" + "@wordpress/is-shallow-equal": "npm:^5.49.0" + "@wordpress/keycodes": "npm:^4.49.0" + "@wordpress/priority-queue": "npm:^3.49.0" + "@wordpress/private-apis": "npm:^1.49.0" + "@wordpress/undo-manager": "npm:^1.49.0" + change-case: "npm:^4.1.2" + mousetrap: "npm:^1.6.5" + use-memo-one: "npm:^1.1.1" + peerDependencies: + "@types/react": ^18.3.27 + react: ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/56c72583d6aa18dd90139785d3c5f4af50193c18a1dc87cb93d95b4361628a411f8e3627f6f96c4e18d8518439ab22fdd78f1250e4f9566b65c1c93b47582a49 + languageName: node + linkType: hard + +"@wordpress/deprecated@npm:^4.49.0": + version: 4.49.0 + resolution: "@wordpress/deprecated@npm:4.49.0" + dependencies: + "@wordpress/hooks": "npm:^4.49.0" + checksum: 10/13ddbf168cb5b4ad863ec73789768b3ecfc83f1edcf2ae6cb6c4d05c74248aa86d7e70cca1b6769acc5daaedb41cedaab7ec0adf0605d90e4d21cc428fb4e1d5 languageName: node linkType: hard -"@wordpress/browserslist-config@npm:^6.46.0": - version: 6.46.0 - resolution: "@wordpress/browserslist-config@npm:6.46.0" - checksum: 10/93298964bf8da22f9bf7a9f1c45280c0b3c59e05492c618b30c8d7425740468b111f96b2bdfc0156af1076c366d413de390962ead744edf76aa82785dc07bb37 +"@wordpress/dom@npm:^4.49.0": + version: 4.49.0 + resolution: "@wordpress/dom@npm:4.49.0" + dependencies: + "@wordpress/deprecated": "npm:^4.49.0" + checksum: 10/1b22c61589f2d539a616aac061b2b15a5e901a9fdd30fbe8cdb82ea8e826843d4f7095d587a6403e95f2f60fe7c23883815935a06299faddeaa0dbb550c24d1b languageName: node linkType: hard -"@wordpress/element@npm:^6.46.0": - version: 6.46.0 - resolution: "@wordpress/element@npm:6.46.0" +"@wordpress/element@npm:^8.1.0": + version: 8.1.0 + resolution: "@wordpress/element@npm:8.1.0" dependencies: "@types/react": "npm:^18.3.27" "@types/react-dom": "npm:^18.3.1" - "@wordpress/escape-html": "npm:^3.46.0" + "@wordpress/deprecated": "npm:^4.49.0" + "@wordpress/escape-html": "npm:^3.49.0" change-case: "npm:^4.1.2" is-plain-object: "npm:^5.0.0" - react: "npm:^18.3.0" - react-dom: "npm:^18.3.0" - checksum: 10/2fae8efc7e237abd796d33c3932985674ce769a114ff305141cd0b2394a1ab1400a04c9cb570ab8b3491538c99308fb0b9144d05bf304150ac22dd095b0b79e4 + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + checksum: 10/d7759ab6f83188aa7422b9dc4cf5330afd3710ab305035ce905912e76a92d295fee9c0c3349e8d199b025f1f79253b24c18c37c64758fa241b936c316aabb837 languageName: node linkType: hard -"@wordpress/escape-html@npm:^3.46.0": - version: 3.46.0 - resolution: "@wordpress/escape-html@npm:3.46.0" - checksum: 10/4161e90db732cbffeb25a629b10fe8d7d4bc0e79262b3ebe3863f17ffbdcbe3ebee87788c4bac409d2d9d0d739a57aa3385f210c10242c4142bc5a3bcad5fa5f +"@wordpress/escape-html@npm:^3.49.0": + version: 3.49.0 + resolution: "@wordpress/escape-html@npm:3.49.0" + checksum: 10/e2516ffa1dbece5beb78cf954d3428bf13c529f8373e0937339cdc17bf1dff994fba6d769777f2c812fd58dcb78505d8add77035c2704372343c450a45644939 languageName: node linkType: hard -"@wordpress/eslint-plugin@npm:^25.1.0": - version: 25.2.0 - resolution: "@wordpress/eslint-plugin@npm:25.2.0" +"@wordpress/eslint-plugin@npm:^25.5.0": + version: 25.5.0 + resolution: "@wordpress/eslint-plugin@npm:25.5.0" dependencies: "@babel/eslint-parser": "npm:^7.28.6" - "@eslint-community/eslint-plugin-eslint-comments": "npm:^4.4.0" + "@eslint-community/eslint-plugin-eslint-comments": "npm:^4.7.0" "@eslint/compat": "npm:^2.0.0" - "@wordpress/babel-preset-default": "npm:^8.46.0" - "@wordpress/prettier-config": "npm:^4.46.0" - "@wordpress/theme": "npm:^0.13.0" + "@wordpress/babel-preset-default": "npm:^8.49.0" + "@wordpress/prettier-config": "npm:^4.49.0" + "@wordpress/theme": "npm:^0.16.0" cosmiconfig: "npm:^7.0.0" eslint-config-prettier: "npm:^10.0.0" eslint-import-resolver-typescript: "npm:^4.4.4" eslint-plugin-import: "npm:^2.31.0" - eslint-plugin-jest: "npm:^28.0.0" + eslint-plugin-jest: "npm:^28.11.0" eslint-plugin-jsdoc: "npm:^50.0.0" eslint-plugin-jsx-a11y: "npm:^6.10.0" eslint-plugin-playwright: "npm:^2.1.0" - eslint-plugin-prettier: "npm:^5.0.0" + eslint-plugin-prettier: "npm:^5.5.5" eslint-plugin-react: "npm:^7.37.0" - eslint-plugin-react-hooks: "npm:7.1.1" + eslint-plugin-react-hooks: "npm:^7.1.1" globals: "npm:^16.0.0" requireindex: "npm:^1.2.0" - typescript-eslint: "npm:^8.0.0" + typescript-eslint: "npm:^8.57.1" peerDependencies: "@babel/core": ">=7" eslint: ^9.0.0 || ^10.0.0 @@ -3029,64 +2932,130 @@ __metadata: optional: true typescript: optional: true - checksum: 10/e97a0a13c16751a01425dab307becf5ab06b4c6c5c366f213de45ba9a614c49840b74c39d1982af6543d58c0c767defb27aa1dc4acb3145b396b778d4d501ae7 + checksum: 10/b1cbdc69599549613ea03daa1b53414124be2dbcbc5a42f7ae507bf4650e698e0836ba53895815622d20c14b801182c3afa4576e2c6b50ffa608d32bf1a3532a + languageName: node + linkType: hard + +"@wordpress/hooks@npm:^4.49.0": + version: 4.49.0 + resolution: "@wordpress/hooks@npm:4.49.0" + checksum: 10/f75fa3b3919b5d8d5745c7dc8be825e7cb9bfaec6dec8341b165eab0c07cf83fff47d6c9538d6574b1b57e7ccaaf43a41f305e15c2d15d5c607a0bae4283dfdd + languageName: node + linkType: hard + +"@wordpress/i18n@npm:^6.22.0": + version: 6.22.0 + resolution: "@wordpress/i18n@npm:6.22.0" + dependencies: + "@tannin/sprintf": "npm:^1.3.2" + "@wordpress/hooks": "npm:^4.49.0" + gettext-parser: "npm:^1.3.1" + memize: "npm:^2.1.0" + tannin: "npm:^1.2.0" + bin: + pot-to-php: tools/pot-to-php.js + checksum: 10/4c3a3324781ca0f9d2964906cb57e791dc85196e9dcf01eefbf8a495dbab3fe2a29e85dedbd028e1564a7672020e8e74de33e7afa2dd7a3ba8621911fa7976c0 + languageName: node + linkType: hard + +"@wordpress/is-shallow-equal@npm:^5.49.0": + version: 5.49.0 + resolution: "@wordpress/is-shallow-equal@npm:5.49.0" + checksum: 10/7e5976b30427054e34ddca66a7f942759eca37505f85be15c3bd5c95da781bcb54bea40a17f86e12b6c2ab61f132cbaf6f9bb5a3a9bbed7e39f53faf6131881f languageName: node linkType: hard -"@wordpress/hooks@npm:^4.45.0": - version: 4.46.0 - resolution: "@wordpress/hooks@npm:4.46.0" - checksum: 10/f48f1c09aa92445e1ce634f1ca5481503482d463528719ad2c56aa209ece5761eb73c22a83f92bf9f49d99b1e6d0e70621f3cbdbe1a740f3a94b53e8d979dc12 +"@wordpress/keycodes@npm:^4.49.0": + version: 4.49.0 + resolution: "@wordpress/keycodes@npm:4.49.0" + dependencies: + "@wordpress/i18n": "npm:^6.22.0" + peerDependencies: + "@types/react": ^18.3.27 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/5e33bbfd99eb0ed2a657234ca3bd388e511912a27f75eb69306460dfa44fd3e876fc0a5bcd33f059d92a6051fa79f248fab493c852848cb970e262ca3d91b4be languageName: node linkType: hard -"@wordpress/prettier-config@npm:^4.46.0": - version: 4.46.0 - resolution: "@wordpress/prettier-config@npm:4.46.0" +"@wordpress/prettier-config@npm:^4.49.0": + version: 4.49.0 + resolution: "@wordpress/prettier-config@npm:4.49.0" peerDependencies: prettier: ">=3" - checksum: 10/bec80e58fd820d026ce44cc53bca091879af41ba463e510885d515fb467edc46054a29bccfc176177f19e36770c8eda4230f645fe83ff6db3450af0b64ae5259 + checksum: 10/40c0e353658443a62f08b06f698f72706c6be80e1aee919ab7e42f926e96195d6d731be699374184448f835aec28d2156797b7b7dc40cffa9b024308468b0299 languageName: node linkType: hard -"@wordpress/private-apis@npm:^1.46.0": - version: 1.46.0 - resolution: "@wordpress/private-apis@npm:1.46.0" - checksum: 10/0369fe18b6cad655c57afb5df99c32967ee9414cea6587858bf8e4c36ece5bff4fc830bc733e9430c878610193a80c4078f07938f811cb5ea50b9f9cfcdc17c3 +"@wordpress/priority-queue@npm:^3.49.0": + version: 3.49.0 + resolution: "@wordpress/priority-queue@npm:3.49.0" + dependencies: + requestidlecallback: "npm:^0.3.0" + checksum: 10/72bbaecad841dabad23f488572f89169a21afde8f4f5bf910f5598eff76d0f8ef9e4d3fb36b656bcdce17c269f8b0b573b73e7d1bc9d87041c27ffc1b214a50e languageName: node linkType: hard -"@wordpress/style-runtime@npm:^0.2.0": - version: 0.2.0 - resolution: "@wordpress/style-runtime@npm:0.2.0" - checksum: 10/5f0bd82e478bbb62359d89206a5b53899a1e438470822740d6f79fba917717e21f7d23729d50732dfe1cc704b24d9af4784fe5bd67c9e771190381d14c91e5f4 +"@wordpress/private-apis@npm:^1.49.0": + version: 1.49.0 + resolution: "@wordpress/private-apis@npm:1.49.0" + checksum: 10/1ab52c4e1da0526795eba494929c2c2ff63aee6c25f67b6d7c99f46d027f6edeeaad61d77f761c3bb6b9b6a65bbd91e8a3c8cf38cb34565c8d27b9ad4533eb4f languageName: node linkType: hard -"@wordpress/theme@npm:^0.13.0": - version: 0.13.0 - resolution: "@wordpress/theme@npm:0.13.0" +"@wordpress/style-runtime@npm:^0.5.0": + version: 0.5.0 + resolution: "@wordpress/style-runtime@npm:0.5.0" + checksum: 10/36dc5ac2759d43ed10de368781f3d36b695718b8cf8eacd309eea7badc8e9fdc05b05c590e25e1b5b96a7f06d930dafa22479e005673498441ff80ce85971fed + languageName: node + linkType: hard + +"@wordpress/theme@npm:^0.16.0": + version: 0.16.0 + resolution: "@wordpress/theme@npm:0.16.0" dependencies: - "@wordpress/element": "npm:^6.46.0" - "@wordpress/private-apis": "npm:^1.46.0" - "@wordpress/style-runtime": "npm:^0.2.0" + "@wordpress/compose": "npm:^8.2.0" + "@wordpress/element": "npm:^8.1.0" + "@wordpress/style-runtime": "npm:^0.5.0" colorjs.io: "npm:^0.6.0" memize: "npm:^2.1.0" peerDependencies: + "@types/react": ^18.3.27 + esbuild: ^0.27.2 + postcss: ^8.0.0 react: ^18.0.0 react-dom: ^18.0.0 stylelint: ^16.8.2 + vite: ^7.3.2 peerDependenciesMeta: + "@types/react": + optional: true + esbuild: + optional: true + postcss: + optional: true stylelint: optional: true - checksum: 10/a9a96b0e829d515581d1d8d124f63f3c2cdc105914f4ba3ebf1b326deaddc3876d6925f98bc01aabac99f0d8c24c2ef57e76006cc6669363b0ef4eecc2cbd1fd + vite: + optional: true + checksum: 10/fbcb6c6f2bdc76532ddaedbb247543192d7181b5a84871d631ede71e467c427cdfb9334b45bd77580b18705e693d133622bebba5ab2e3e9ac25313c08d8a79e4 languageName: node linkType: hard -"@wordpress/warning@npm:^3.46.0": - version: 3.46.0 - resolution: "@wordpress/warning@npm:3.46.0" - checksum: 10/b6a1e5597af9a6de6eb1cfa1682b82a4335fad05f9753d461ad05ad3f95b830aeae1c2befcc84ad0a2524ac32abeb4baf19ebf1a57f7345eb21cb8f7ae26bcb2 +"@wordpress/undo-manager@npm:^1.49.0": + version: 1.49.0 + resolution: "@wordpress/undo-manager@npm:1.49.0" + dependencies: + "@wordpress/is-shallow-equal": "npm:^5.49.0" + checksum: 10/75566528eeec5820e053e8c09060982c8315e9d2254272ff30ee94f705ec6fcac1aafaeb1847b7e3a29272254706eac17e389f08129beb802b46523419b75328 + languageName: node + linkType: hard + +"@wordpress/warning@npm:^3.49.0": + version: 3.49.0 + resolution: "@wordpress/warning@npm:3.49.0" + checksum: 10/cc81725596e314bf34880fbdd4793ad080b4874ed4ab080710adb5bfd882ffee75a1d6b5b1f7859b620381931feba6f21e778eca8c1d421492921742b43b7d24 languageName: node linkType: hard @@ -3104,17 +3073,10 @@ __metadata: languageName: node linkType: hard -"@zxing/text-encoding@npm:0.9.0": - version: 0.9.0 - resolution: "@zxing/text-encoding@npm:0.9.0" - checksum: 10/268e4ef64b8eaa32b990240bdfd1f7b3e2b501a6ed866a565f7c9747f04ac884fbe0537fe12bb05d9241b98fb111270c0fd0023ef0a02d23a6619b4589e98f6b - languageName: node - linkType: hard - -"abbrev@npm:^4.0.0": - version: 4.0.0 - resolution: "abbrev@npm:4.0.0" - checksum: 10/e2f0c6a6708ad738b3e8f50233f4800de31ad41a6cdc50e0cbe51b76fed69fd0213516d92c15ce1a9985fca71a14606a9be22bf00f8475a58987b9bfb671c582 +"abbrev@npm:^5.0.0": + version: 5.0.0 + resolution: "abbrev@npm:5.0.0" + checksum: 10/a32641fb7a8ba0ad6f65efda80a632c965a2567f52c988897bffc47f473c4e9c3f0166de19d939866b1ed58ec50ce36f697d54a476589ca2706f8b5605ed41f0 languageName: node linkType: hard @@ -3137,11 +3099,11 @@ __metadata: linkType: hard "acorn@npm:^8.15.0, acorn@npm:^8.16.0": - version: 8.16.0 - resolution: "acorn@npm:8.16.0" + version: 8.17.0 + resolution: "acorn@npm:8.17.0" bin: acorn: bin/acorn - checksum: 10/690c673bb4d61b38ef82795fab58526471ad7f7e67c0e40c4ff1e10ecd80ce5312554ef633c9995bfc4e6d170cef165711f9ca9e49040b62c0c66fbf2dd3df2b + checksum: 10/2eea1588075124df569b15995423204055c5575ad992283025dddfcb557a0340de7d75cc1bc25dca8df148c60c4222e576e0e519965f0ec7f86f6085c8428824 languageName: node linkType: hard @@ -3392,7 +3354,7 @@ __metadata: languageName: node linkType: hard -"asn1js@npm:^3.0.10, asn1js@npm:^3.0.6": +"asn1js@npm:^3.0.10": version: 3.0.10 resolution: "asn1js@npm:3.0.10" dependencies: @@ -3434,9 +3396,9 @@ __metadata: linkType: hard "axe-core@npm:^4.10.0": - version: 4.11.4 - resolution: "axe-core@npm:4.11.4" - checksum: 10/49095daa422d05d99a90b39301a3b5c971e234a4593403dfd6701df637a3e550bcfd7bd096709c5643564dd069208513247791f367790e0605d15386fb2a7bfe + version: 4.12.1 + resolution: "axe-core@npm:4.12.1" + checksum: 10/08c2ff348524b57dfb68f4e866b1a6363147203b721c734ed00090c16eb911cd015cf71fd96a8bafe42609715f649d21b2e188fae622ac8ef6144b144582f6c4 languageName: node linkType: hard @@ -3504,7 +3466,7 @@ __metadata: languageName: node linkType: hard -"babel-plugin-polyfill-corejs2@npm:^0.4.10, babel-plugin-polyfill-corejs2@npm:^0.4.15": +"babel-plugin-polyfill-corejs2@npm:^0.4.14, babel-plugin-polyfill-corejs2@npm:^0.4.15": version: 0.4.17 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.17" dependencies: @@ -3517,15 +3479,15 @@ __metadata: languageName: node linkType: hard -"babel-plugin-polyfill-corejs3@npm:^0.10.6": - version: 0.10.6 - resolution: "babel-plugin-polyfill-corejs3@npm:0.10.6" +"babel-plugin-polyfill-corejs3@npm:^0.13.0": + version: 0.13.0 + resolution: "babel-plugin-polyfill-corejs3@npm:0.13.0" dependencies: - "@babel/helper-define-polyfill-provider": "npm:^0.6.2" - core-js-compat: "npm:^3.38.0" + "@babel/helper-define-polyfill-provider": "npm:^0.6.5" + core-js-compat: "npm:^3.43.0" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10/360ac9054a57a18c540059dc627ad5d84d15f79790cb3d84d19a02eec7188c67d08a07db789c3822d6f5df22d918e296d1f27c4055fec2e287d328f09ea8a78a + checksum: 10/aa36f9a09521404dd0569a4cbd5f88aa4b9abff59508749abde5d09d66c746012fb94ed1e6e2c8be3710939a2a4c6293ee3be889125d7611c93e5897d9e5babd languageName: node linkType: hard @@ -3541,7 +3503,7 @@ __metadata: languageName: node linkType: hard -"babel-plugin-polyfill-regenerator@npm:^0.6.1, babel-plugin-polyfill-regenerator@npm:^0.6.6": +"babel-plugin-polyfill-regenerator@npm:^0.6.5, babel-plugin-polyfill-regenerator@npm:^0.6.6": version: 0.6.8 resolution: "babel-plugin-polyfill-regenerator@npm:0.6.8" dependencies: @@ -3603,12 +3565,12 @@ __metadata: languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.10.12": - version: 2.10.32 - resolution: "baseline-browser-mapping@npm:2.10.32" +"baseline-browser-mapping@npm:^2.10.38": + version: 2.10.40 + resolution: "baseline-browser-mapping@npm:2.10.40" bin: baseline-browser-mapping: dist/cli.cjs - checksum: 10/65e8fa5ae614ed266d24e69aab517e46a7da5bbe16c46fa8373ae22007ab9f492c05d7308240e0a8b55bd0e899c67fd82a0e225f78ecb7e9164f23b0791f2784 + checksum: 10/6b947f3295c9e900d22ba53c01c0acd38e3574280dcdf51ff7f1847086caf6d4c354a8779a9920f8892aa3d2c939a48e3094ea0a725a221b0f832352db93e9fe languageName: node linkType: hard @@ -3620,21 +3582,21 @@ __metadata: linkType: hard "brace-expansion@npm:^1.1.7": - version: 1.1.14 - resolution: "brace-expansion@npm:1.1.14" + version: 1.1.15 + resolution: "brace-expansion@npm:1.1.15" dependencies: balanced-match: "npm:^1.0.0" concat-map: "npm:0.0.1" - checksum: 10/2de747a5891ea0d3a1946ea1ae26e056a47f7ea8d42a3009e1736ec3a31a5aa69a3c5da59d998426773553afe4c258e5b12d7953b534fa7f2cf12ce92eed4931 + checksum: 10/f2a950034e670523cc186da61aabe3beab74b1b8a7c74a756bf6b172dad1917312f255d9ec46906c9f0cab530868095d8c143918576930dd0e1323c3803850f1 languageName: node linkType: hard "brace-expansion@npm:^2.0.2": - version: 2.1.0 - resolution: "brace-expansion@npm:2.1.0" + version: 2.1.1 + resolution: "brace-expansion@npm:2.1.1" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10/c77a7a64aabf94b8d5913955adb4f36957917565374461355bb4276830c027a313d981f32410cea9e38f52573e7eb776d02fe05091c3a79a061958d97e4d2b43 + checksum: 10/4681c533dc4e6c77b3ad795b38683d297fd03c739a17bfb2a338529fa7dcf4540683a79dcd662905f4c5b0db7cfda18daafcd18dd1bbf7c3b076fe0c9c3487eb languageName: node linkType: hard @@ -3648,17 +3610,17 @@ __metadata: linkType: hard "browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.24.0, browserslist@npm:^4.28.1, browserslist@npm:^4.28.2": - version: 4.28.2 - resolution: "browserslist@npm:4.28.2" + version: 4.28.4 + resolution: "browserslist@npm:4.28.4" dependencies: - baseline-browser-mapping: "npm:^2.10.12" - caniuse-lite: "npm:^1.0.30001782" - electron-to-chromium: "npm:^1.5.328" - node-releases: "npm:^2.0.36" + baseline-browser-mapping: "npm:^2.10.38" + caniuse-lite: "npm:^1.0.30001799" + electron-to-chromium: "npm:^1.5.376" + node-releases: "npm:^2.0.48" update-browserslist-db: "npm:^1.2.3" bin: browserslist: cli.js - checksum: 10/cff88386e5b5ba5614c9063bd32ef94865bba22b6a381844c7d09ea1eea62a2247e7106e516abdbfda6b75b9986044c991dfe45f92f10add5ad63dccc07589ec + checksum: 10/beb030f5b301b6af3fc7df3291d56f9edb34b7e4bd3cdc50d379c84c3410ad1ba013602952bcec1d98af940cf6596609fc81ef09d200f7f2e25e9f84d9d38201 languageName: node linkType: hard @@ -3753,10 +3715,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001782": - version: 1.0.30001793 - resolution: "caniuse-lite@npm:1.0.30001793" - checksum: 10/5a1ac39f2f174e86d8320f394a5dfbeab98041722b13d02a21fe47afc2723bc754ea3716a1f276f8462bcbbc3016e82e6f155ebde0881e537af463b5eac1816b +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001799": + version: 1.0.30001799 + resolution: "caniuse-lite@npm:1.0.30001799" + checksum: 10/eb90443f1e4e4ac7cfe3686d43f0d132c0b552d0d896c0520e7306f2ddf743b4dd5380a7b8adc5ca8d250247966a6cf32cb042930dbc1df452e8623ad92c57e2 languageName: node linkType: hard @@ -3948,7 +3910,7 @@ __metadata: languageName: node linkType: hard -"core-js-compat@npm:^3.38.0, core-js-compat@npm:^3.38.1, core-js-compat@npm:^3.48.0": +"core-js-compat@npm:^3.43.0, core-js-compat@npm:^3.48.0": version: 3.49.0 resolution: "core-js-compat@npm:3.49.0" dependencies: @@ -4403,10 +4365,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.328": - version: 1.5.361 - resolution: "electron-to-chromium@npm:1.5.361" - checksum: 10/1144dc40c7e0588c179fbc64ac97215a9af897f95137191a857961f2ea889126a8ccc412bab35990906cf65645251b45245403f91a7cd293a970c22cbb58a905 +"electron-to-chromium@npm:^1.5.376": + version: 1.5.380 + resolution: "electron-to-chromium@npm:1.5.380" + checksum: 10/525920d87249f80c439f55541844558cd9a36c40f07545a778c60e1db30bc554de9cd1f410d1cdf0ae9756844c3f26f86c1ec1e03fd50fdfc84f018edd78eb98 languageName: node linkType: hard @@ -4431,13 +4393,22 @@ __metadata: languageName: node linkType: hard -"enhanced-resolve@npm:^5.21.4": - version: 5.22.0 - resolution: "enhanced-resolve@npm:5.22.0" +"encoding@npm:^0.1.12": + version: 0.1.13 + resolution: "encoding@npm:0.1.13" + dependencies: + iconv-lite: "npm:^0.6.2" + checksum: 10/bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f + languageName: node + linkType: hard + +"enhanced-resolve@npm:^5.22.2": + version: 5.24.1 + resolution: "enhanced-resolve@npm:5.24.1" dependencies: graceful-fs: "npm:^4.2.4" tapable: "npm:^2.3.3" - checksum: 10/faf794fe6edbad45beee2f50418be98507de7d961e7d8c65e0e3d3fcf30fc39ca467e55f4b02ed0a9a65a58d0ca7b17cc3f5c11f574c36acc47a53b16c6f0825 + checksum: 10/161f52840ad84390ad5b9ac611692b0b45dd12a673455df697bb824a6fef47490ee7dadbe9fd7f003d9a576be8741092b7c0d9e8147e8fb47dbad698f2de2a9b languageName: node linkType: hard @@ -4462,7 +4433,7 @@ __metadata: languageName: node linkType: hard -"envinfo@npm:^7.14.0": +"envinfo@npm:^7.21.0": version: 7.21.0 resolution: "envinfo@npm:7.21.0" bin: @@ -4480,6 +4451,18 @@ __metadata: languageName: node linkType: hard +"es-abstract-get@npm:^1.0.0": + version: 1.0.0 + resolution: "es-abstract-get@npm:1.0.0" + dependencies: + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.2" + is-callable: "npm:^1.2.7" + object-inspect: "npm:^1.13.4" + checksum: 10/a2bfa7536529a21c8590670a69c0c4583e531f92dbc420c13f680bf906215f510d9d14b18e2e4a26e8b07100ab28719811dbce822e43fe87305a5a9069cda24e + languageName: node + linkType: hard + "es-abstract@npm:^1.17.5, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0, es-abstract@npm:^1.24.2": version: 1.24.2 resolution: "es-abstract@npm:1.24.2" @@ -4557,8 +4540,8 @@ __metadata: linkType: hard "es-iterator-helpers@npm:^1.2.1": - version: 1.3.2 - resolution: "es-iterator-helpers@npm:1.3.2" + version: 1.3.3 + resolution: "es-iterator-helpers@npm:1.3.3" dependencies: call-bind: "npm:^1.0.9" call-bound: "npm:^1.0.4" @@ -4576,7 +4559,7 @@ __metadata: internal-slot: "npm:^1.1.0" iterator.prototype: "npm:^1.1.5" math-intrinsics: "npm:^1.1.0" - checksum: 10/6b8f9c55c6bb3d5edbae777e892a18e093a7d7a1aa7e8f14da908476b84fbf55769a51356a674819ec95e9655ecdc873a9b7fb5b719320ef67e1b203c77f0bad + checksum: 10/53a45f693088f51d8aeda4034f1be9d7d4fc8505ee58f70bbb237a63729efccf2f96225e15e2b2ac7815104739e6d244019637609ee7c9ee171b8248585ecfae languageName: node linkType: hard @@ -4587,7 +4570,7 @@ __metadata: languageName: node linkType: hard -"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1, es-object-atoms@npm:^1.1.2": version: 1.1.2 resolution: "es-object-atoms@npm:1.1.2" dependencies: @@ -4618,13 +4601,16 @@ __metadata: linkType: hard "es-to-primitive@npm:^1.3.0": - version: 1.3.0 - resolution: "es-to-primitive@npm:1.3.0" + version: 1.3.4 + resolution: "es-to-primitive@npm:1.3.4" dependencies: + es-abstract-get: "npm:^1.0.0" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" is-callable: "npm:^1.2.7" - is-date-object: "npm:^1.0.5" - is-symbol: "npm:^1.0.4" - checksum: 10/17faf35c221aad59a16286cbf58ef6f080bf3c485dff202c490d074d8e74da07884e29b852c245d894eac84f73c58330ec956dfd6d02c0b449d75eb1012a3f9b + is-date-object: "npm:^1.1.0" + is-symbol: "npm:^1.1.1" + checksum: 10/7a68d778c06de7de960682c57f4103259ca3cb6dc720b8908a027b04bf1216ae4c1688a8999443f871f03efaf8ec7c24b28f33892e2a553c2a4e3bc6bd0e276a languageName: node linkType: hard @@ -4687,8 +4673,8 @@ __metadata: linkType: hard "eslint-import-resolver-typescript@npm:^4.4.4": - version: 4.4.4 - resolution: "eslint-import-resolver-typescript@npm:4.4.4" + version: 4.4.5 + resolution: "eslint-import-resolver-typescript@npm:4.4.5" dependencies: debug: "npm:^4.4.1" eslint-import-context: "npm:^0.1.8" @@ -4706,19 +4692,19 @@ __metadata: optional: true eslint-plugin-import-x: optional: true - checksum: 10/4f871f6d1a04c55c2087c5ff1030f783a29abb59901b354d7ef58a0fc687d379dcbd08cf377cddeb7e19f26dd380d32d85ee4760e9410a059639d2b3df7d1ff3 + checksum: 10/f889b885abbe8b3517286de7f30289cf9318357b124ef2b92948c77d662901dd6b8fba0dffebedf64fb68a2bee0a416b5ccbf1f67f6502736964f972a72cef8e languageName: node linkType: hard "eslint-module-utils@npm:^2.12.1": - version: 2.12.1 - resolution: "eslint-module-utils@npm:2.12.1" + version: 2.13.0 + resolution: "eslint-module-utils@npm:2.13.0" dependencies: debug: "npm:^3.2.7" peerDependenciesMeta: eslint: optional: true - checksum: 10/bd25d6610ec3abaa50e8f1beb0119541562bbb8dd02c035c7e887976fe1e0c5dd8175f4607ca8d86d1146df24d52a071bd3d1dd329f6902bd58df805a8ca16d3 + checksum: 10/cecb2c341bddc85314b3cf3cf9354e894b9af3047c28db248ad498166847029c710caf605dc43f903086edb0ce9bd83a61b21ed217eb2237807c742d33ccf7fb languageName: node linkType: hard @@ -4751,7 +4737,7 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jest@npm:^28.0.0": +"eslint-plugin-jest@npm:^28.11.0": version: 28.14.0 resolution: "eslint-plugin-jest@npm:28.14.0" dependencies: @@ -4825,12 +4811,12 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-prettier@npm:^5.0.0": - version: 5.5.5 - resolution: "eslint-plugin-prettier@npm:5.5.5" +"eslint-plugin-prettier@npm:^5.5.5": + version: 5.5.6 + resolution: "eslint-plugin-prettier@npm:5.5.6" dependencies: prettier-linter-helpers: "npm:^1.0.1" - synckit: "npm:^0.11.12" + synckit: "npm:^0.11.13" peerDependencies: "@types/eslint": ">=8.0.0" eslint: ">=8.0.0" @@ -4841,11 +4827,11 @@ __metadata: optional: true eslint-config-prettier: optional: true - checksum: 10/36c22c2fa2fd7c61ed292af1280e1d8f94dfe1671eacc5a503a249ca4b27fd226dbf6a1820457d611915926946f42729488d2dc7a5c320601e6cf1fad0d28f66 + checksum: 10/18a0189e084dd5f1e4e3944a8ff89f20d69c14f100965033a94edd8de16fb7abdcbf226fbc40f27b7ac6cfdb779065272dd8a9e6ca34ed86a59d895e630bd48b languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:7.1.1": +"eslint-plugin-react-hooks@npm:^7.1.1": version: 7.1.1 resolution: "eslint-plugin-react-hooks@npm:7.1.1" dependencies: @@ -4938,16 +4924,16 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^10.3.0": - version: 10.4.0 - resolution: "eslint@npm:10.4.0" +"eslint@npm:^10.6.0": + version: 10.6.0 + resolution: "eslint@npm:10.6.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.8.0" "@eslint-community/regexpp": "npm:^4.12.2" "@eslint/config-array": "npm:^0.23.5" "@eslint/config-helpers": "npm:^0.6.0" "@eslint/core": "npm:^1.2.1" - "@eslint/plugin-kit": "npm:^0.7.1" + "@eslint/plugin-kit": "npm:^0.7.2" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.4.2" @@ -4979,7 +4965,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10/ab20a8fd250ee7c31a162d35a71e534a4f959736a7198661e5c963069b12880a53acfb349f3dc94fa82c91c74dd0594accbd878576bf65c33fa4225342ad7df6 + checksum: 10/36d02cdbe37668e601f255917c605d10a5d06d278648dc72cec21fe23b7b60a53453241b32c382ba8107533cacba70aeec97581cc4f4bb591544b1ada2516335 languageName: node linkType: hard @@ -5131,13 +5117,6 @@ __metadata: languageName: node linkType: hard -"fastest-levenshtein@npm:^1.0.12": - version: 1.0.16 - resolution: "fastest-levenshtein@npm:1.0.16" - checksum: 10/ee85d33b5cef592033f70e1c13ae8624055950b4eb832435099cd56aa313d7f251b873bedbc06a517adfaff7b31756d139535991e2406967438e03a1bf1b008e - languageName: node - linkType: hard - "fb-watchman@npm:^2.0.2": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" @@ -5267,16 +5246,19 @@ __metadata: linkType: hard "function.prototype.name@npm:^1.1.6, function.prototype.name@npm:^1.1.8": - version: 1.1.8 - resolution: "function.prototype.name@npm:1.1.8" + version: 1.2.0 + resolution: "function.prototype.name@npm:1.2.0" dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.3" - define-properties: "npm:^1.2.1" + call-bind: "npm:^1.0.9" + call-bound: "npm:^1.0.4" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" functions-have-names: "npm:^1.2.3" - hasown: "npm:^2.0.2" + has-property-descriptors: "npm:^1.0.2" + hasown: "npm:^2.0.4" is-callable: "npm:^1.2.7" - checksum: 10/25b9e5bea936732a6f0c0c08db58cc0d609ac1ed458c6a07ead46b32e7b9bf3fe5887796c3f83d35994efbc4fdde81c08ac64135b2c399b8f2113968d44082bc + is-document.all: "npm:^1.0.0" + checksum: 10/ad662230bc2b9e971625222b462142b34aa23c70ca58fb4fa72e226bb9106a5752be5c7d8986de7ce5cfb959e5317200d70d88d96359605a165ed1c8cb515223 languageName: node linkType: hard @@ -5373,6 +5355,16 @@ __metadata: languageName: node linkType: hard +"gettext-parser@npm:^1.3.1": + version: 1.4.0 + resolution: "gettext-parser@npm:1.4.0" + dependencies: + encoding: "npm:^0.1.12" + safe-buffer: "npm:^5.1.1" + checksum: 10/c2addedbe1c0064b609ccf3e25ed3904f92a671375f49eceeca2a64d89bcae7ea8a7b9611d93c9d11923c10a3c9d130f40c068b218068e314764eb8c03c28bab + languageName: node + linkType: hard + "glob-parent@npm:^6.0.2": version: 6.0.2 resolution: "glob-parent@npm:6.0.2" @@ -5382,13 +5374,6 @@ __metadata: languageName: node linkType: hard -"glob-to-regexp@npm:^0.4.1": - version: 0.4.1 - resolution: "glob-to-regexp@npm:0.4.1" - checksum: 10/9009529195a955c40d7b9690794aeff5ba665cc38f1519e111c58bb54366fd0c106bde80acf97ba4e533208eb53422c83b136611a54c5fefb1edd8dc267cb62e - languageName: node - linkType: hard - "glob@npm:^10.5.0": version: 10.5.0 resolution: "glob@npm:10.5.0" @@ -5437,10 +5422,10 @@ __metadata: languageName: node linkType: hard -"globals@npm:^17.3.0, globals@npm:^17.6.0": - version: 17.6.0 - resolution: "globals@npm:17.6.0" - checksum: 10/2bf0febf31c942edee6f4eca7e939a9c885f8ecfb767048b1c4dd2a32008d0ab136e6076665d76b44b29c2571bbbc1681371caab05fd8ee0067c7618e841b89d +"globals@npm:^17.3.0, globals@npm:^17.7.0": + version: 17.7.0 + resolution: "globals@npm:17.7.0" + checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4 languageName: node linkType: hard @@ -5516,12 +5501,12 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.2, hasown@npm:^2.0.3": - version: 2.0.3 - resolution: "hasown@npm:2.0.3" +"hasown@npm:^2.0.2, hasown@npm:^2.0.3, hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" dependencies: function-bind: "npm:^1.1.2" - checksum: 10/619526379cda755409d856cbf3c65b82ea342151719a0a550920cf7d6a7f58f7cf079e5a78f3acd162324fc784a3d3d6f6f61aff613b47a0163c16fbe09ea89f + checksum: 10/13823863ae48161068b4c51606a3128451c66f14545a5169d667fe9fca168dcd38c27570c7a299e32ef844b8da3d55def7fe88602f8970d4311fb543ee88001a languageName: node linkType: hard @@ -5529,27 +5514,27 @@ __metadata: version: 0.0.0-use.local resolution: "hcaptcha@workspace:." dependencies: - "@babel/core": "npm:^7.29.0" - "@babel/preset-env": "npm:^7.29.5" - "@inrupt/jest-jsdom-polyfills": "npm:^4.1.1" - "@wordpress/eslint-plugin": "npm:^25.1.0" - "@wordpress/hooks": "npm:^4.45.0" + "@babel/core": "npm:^7.29.7" + "@babel/preset-env": "npm:^7.29.7" + "@inrupt/jest-jsdom-polyfills": "npm:^4.1.4" + "@wordpress/eslint-plugin": "npm:^25.5.0" + "@wordpress/hooks": "npm:^4.49.0" babel-loader: "npm:^10.1.1" css-loader: "npm:^7.1.4" css-minimizer-webpack-plugin: "npm:^8.0.0" - eslint: "npm:^10.3.0" + eslint: "npm:^10.6.0" glob: "npm:^13.0.6" - globals: "npm:^17.6.0" + globals: "npm:^17.7.0" jest: "npm:^30.4.2" jest-environment-jsdom: "npm:^30.4.1" jest-fetch-mock: "npm:^3.0.3" jquery: "npm:^3.7.1" mini-css-extract-plugin: "npm:^2.10.2" - prettier: "npm:^3.8.3" - terser-webpack-plugin: "npm:^5.6.0" + prettier: "npm:^3.8.5" + terser-webpack-plugin: "npm:^5.6.1" typescript: "npm:^6.0.3" - webpack: "npm:^5.106.2" - webpack-cli: "npm:^7.0.2" + webpack: "npm:^5.108.1" + webpack-cli: "npm:^7.1.0" webpack-remove-empty-scripts: "npm:^1.1.1" languageName: unknown linkType: soft @@ -5623,7 +5608,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.6.3": +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -5665,7 +5650,7 @@ __metadata: languageName: node linkType: hard -"import-local@npm:^3.0.2, import-local@npm:^3.2.0": +"import-local@npm:^3.2.0": version: 3.2.0 resolution: "import-local@npm:3.2.0" dependencies: @@ -5694,7 +5679,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:^2.0.3": +"inherits@npm:2": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 @@ -5719,16 +5704,6 @@ __metadata: languageName: node linkType: hard -"is-arguments@npm:^1.0.4": - version: 1.2.0 - resolution: "is-arguments@npm:1.2.0" - dependencies: - call-bound: "npm:^1.0.2" - has-tostringtag: "npm:^1.0.2" - checksum: 10/471a8ef631b8ee8829c43a8ab05c081700c0e25180c73d19f3bf819c1a8448c426a9e8e601f278973eca68966384b16ceb78b8c63af795b099cd199ea5afc457 - languageName: node - linkType: hard - "is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" @@ -5815,7 +5790,7 @@ __metadata: languageName: node linkType: hard -"is-date-object@npm:^1.0.5, is-date-object@npm:^1.1.0": +"is-date-object@npm:^1.1.0": version: 1.1.0 resolution: "is-date-object@npm:1.1.0" dependencies: @@ -5825,6 +5800,15 @@ __metadata: languageName: node linkType: hard +"is-document.all@npm:^1.0.0": + version: 1.0.0 + resolution: "is-document.all@npm:1.0.0" + dependencies: + call-bound: "npm:^1.0.4" + checksum: 10/c76fa391105f180e9d34bf219ab1db325b4f883d2d82c789dbf9a628e4213c97411f038f36b7d096d85f5ddc1fda6e22e9d8d7c65b89ad1ee5d4d1e5a2a4c077 + languageName: node + linkType: hard + "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -5855,7 +5839,7 @@ __metadata: languageName: node linkType: hard -"is-generator-function@npm:^1.0.10, is-generator-function@npm:^1.0.7": +"is-generator-function@npm:^1.0.10": version: 1.1.2 resolution: "is-generator-function@npm:1.1.2" dependencies: @@ -5969,7 +5953,7 @@ __metadata: languageName: node linkType: hard -"is-symbol@npm:^1.0.4, is-symbol@npm:^1.1.1": +"is-symbol@npm:^1.1.1": version: 1.1.1 resolution: "is-symbol@npm:1.1.1" dependencies: @@ -5980,7 +5964,7 @@ __metadata: languageName: node linkType: hard -"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15, is-typed-array@npm:^1.1.3": +"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15": version: 1.1.15 resolution: "is-typed-array@npm:1.1.15" dependencies: @@ -6611,14 +6595,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.1": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" +"js-yaml@npm:^4.3.0": + version: 4.3.0 + resolution: "js-yaml@npm:4.3.0" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10/a52d0519f0f4ef5b4adc1cde466cb54c50d56e2b4a983b9d5c9c0f2f99462047007a6274d7e95617a21d3c91fde3ee6115536ed70991cd645ba8521058b78f77 + checksum: 10/2bcec3a8118d7f744badeb04e14366578d234a736f353d41fe35d2305e4ce2409a8e041d277f07cd6bbc8aaa12128d650a68ce43247072519bede20962d2126f languageName: node linkType: hard @@ -6875,9 +6859,9 @@ __metadata: linkType: hard "lru-cache@npm:^11.0.0": - version: 11.5.0 - resolution: "lru-cache@npm:11.5.0" - checksum: 10/c02af3d6e5e5baff9b52ed1a0850dc97e21a2554308c0feab5663873f9b395ea66b2767ead6e347542c08ab89b04aadb92884eddbcd14d975505687530df99e8 + version: 11.5.1 + resolution: "lru-cache@npm:11.5.1" + checksum: 10/02c4f73967d91fb101f4accf8ebac9e0541e08e16d987bdb9e9737f13e5f2c4bc33c593b98ec30e4486bf899bc97edb36fbd133684b36087336559e41edafdea languageName: node linkType: hard @@ -7003,6 +6987,45 @@ __metadata: languageName: node linkType: hard +"minimizer-webpack-plugin@npm:^5.6.1": + version: 5.6.1 + resolution: "minimizer-webpack-plugin@npm:5.6.1" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.25" + jest-worker: "npm:^27.4.5" + schema-utils: "npm:^4.3.0" + terser: "npm:^5.31.1" + peerDependencies: + webpack: ^5.1.0 + peerDependenciesMeta: + "@minify-html/node": + optional: true + "@swc/core": + optional: true + "@swc/css": + optional: true + "@swc/html": + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + checksum: 10/c9168bea57cdcd338721068996735b68e5660bab775011710e524d8b7673db249b410641e89d8f3f88c0807c4d954f9b874a2b5ba4795e75af9d08c91ed4c40a + languageName: node + linkType: hard + "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3": version: 7.1.3 resolution: "minipass@npm:7.1.3" @@ -7019,6 +7042,13 @@ __metadata: languageName: node linkType: hard +"mousetrap@npm:^1.6.5": + version: 1.6.5 + resolution: "mousetrap@npm:1.6.5" + checksum: 10/2f55e715c8e106fa96135f8f3bc7fe71a3c497e803a451f357ae70598c3994a88c2907780f7abf51df4268c26cfb23f94db8687cd37154d65a0cc74cb530d438 + languageName: node + linkType: hard + "ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -7027,11 +7057,11 @@ __metadata: linkType: hard "nanoid@npm:^3.3.12": - version: 3.3.12 - resolution: "nanoid@npm:3.3.12" + version: 3.3.15 + resolution: "nanoid@npm:3.3.15" bin: nanoid: bin/nanoid.cjs - checksum: 10/6eec280694e2088d18fb802b1e3bfc4578e27b665b7ecfbe36c7356612fea2f814277056e671e2a1529dff551588a652efdc0bfa39f8a3185bc2247be311872e + checksum: 10/13c74a5208d455286f7af46f42ac9f3d7b821b8a719aff8dbd5ad3fb80399c0c63cdd1e92d046ea576e403bec05262fbb7e116beb4cfcd5b5483550372bd94b1 languageName: node linkType: hard @@ -7069,14 +7099,14 @@ __metadata: linkType: hard "node-exports-info@npm:^1.6.0": - version: 1.6.0 - resolution: "node-exports-info@npm:1.6.0" + version: 1.6.2 + resolution: "node-exports-info@npm:1.6.2" dependencies: array.prototype.flatmap: "npm:^1.3.3" es-errors: "npm:^1.3.0" object.entries: "npm:^1.1.9" semver: "npm:^6.3.1" - checksum: 10/0a1667d535f499ac1fe6c6d22f8146bc8b68abc76fa355856219202f6cf5f386027e0ff054e66a22d08be02acbc63fcdc9f98d0fbc97993f5eabc66408fdadad + checksum: 10/6c9593094b3dd9b8a338f31490c0bad6da886a964b1bc6d95416dfe83810d4ab3493e53290b951d131a3148bf529f33bd908302aa4fd57c45c7b3d55e2cff848 languageName: node linkType: hard @@ -7095,22 +7125,22 @@ __metadata: linkType: hard "node-gyp@npm:latest": - version: 12.3.0 - resolution: "node-gyp@npm:12.3.0" + version: 13.0.0 + resolution: "node-gyp@npm:13.0.0" dependencies: env-paths: "npm:^2.2.0" exponential-backoff: "npm:^3.1.1" graceful-fs: "npm:^4.2.6" - nopt: "npm:^9.0.0" - proc-log: "npm:^6.0.0" + nopt: "npm:^10.0.0" + proc-log: "npm:^7.0.0" semver: "npm:^7.3.5" tar: "npm:^7.5.4" tinyglobby: "npm:^0.2.12" undici: "npm:^6.25.0" - which: "npm:^6.0.0" + which: "npm:^7.0.0" bin: node-gyp: bin/node-gyp.js - checksum: 10/cd97bf17f0f3e6288c42cc23a6db8528a98e7530abdb72ab558272906d603362e4558069f99f8a5250bc78f65ff305b1438caca4f1b31c81904a8798c242603e + checksum: 10/12b7b0204d07493c347f59734aaee7531f41540c820ad0e40604e96838ab277f33fb1d70500283dbb66ee02182ebad231b6a13c75644d83e6c94c1ef28009c6a languageName: node linkType: hard @@ -7121,21 +7151,21 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.36": - version: 2.0.46 - resolution: "node-releases@npm:2.0.46" - checksum: 10/47a1fd18bc8330282c3e628ea38629dd137e9f1f16d7b7d3bb033ea24f5c3f917d78e25fbc90bb004d1971c624b1cc3d48023a20d7c214ea23326a75aebb908f +"node-releases@npm:^2.0.48": + version: 2.0.50 + resolution: "node-releases@npm:2.0.50" + checksum: 10/c40306176fc9d43d27b919034fab57ab0753448dfcabeb1ae669128d28f595fba4c329a269b2d7fa9bb7d5a6d2bda8b2bd8fe5e8446226bb809f448fec2e80fb languageName: node linkType: hard -"nopt@npm:^9.0.0": - version: 9.0.0 - resolution: "nopt@npm:9.0.0" +"nopt@npm:^10.0.0": + version: 10.0.1 + resolution: "nopt@npm:10.0.1" dependencies: - abbrev: "npm:^4.0.0" + abbrev: "npm:^5.0.0" bin: nopt: bin/nopt.js - checksum: 10/56a1ccd2ad711fb5115918e2c96828703cddbe12ba2c3bd00591758f6fa30e6f47dd905c59dbfcf9b773f3a293b45996609fb6789ae29d6bfcc3cf3a6f7d9fda + checksum: 10/8021371365e78a2cbab015cac50d8449aa2cc411f0b8f2edb466c1336c3dfee4e61c5bf5bde22ee7dcea80d5f4510a7a8705ed3646c8d782f28b550c62bc4fdf languageName: node linkType: hard @@ -7165,9 +7195,9 @@ __metadata: linkType: hard "nwsapi@npm:^2.2.16": - version: 2.2.23 - resolution: "nwsapi@npm:2.2.23" - checksum: 10/aa4a570039c33d70b51436d1bb533f3e2c33c488ccbe9b09285c46a6cee5ef266fd60103461085c6954ba52460786a8138f042958328c7c1b4763898eb3dadfa + version: 2.2.24 + resolution: "nwsapi@npm:2.2.24" + checksum: 10/08795ffcbc213cbc41b72e5c3b357d9db59e27ff8d88808af015ba7689041954ca1b5d6bfd7aa4d7df64338b9b76f853ca7154ec3de403c2104c1a39ac0ed225 languageName: node linkType: hard @@ -7514,7 +7544,7 @@ __metadata: languageName: node linkType: hard -"possible-typed-array-names@npm:^1.0.0": +"possible-typed-array-names@npm:^1.0.0, possible-typed-array-names@npm:^1.1.0": version: 1.1.0 resolution: "possible-typed-array-names@npm:1.1.0" checksum: 10/2f44137b8d3dd35f4a7ba7469eec1cd9cfbb46ec164b93a5bc1f4c3d68599c9910ee3b91da1d28b4560e9cc8414c3cd56fedc07259c67e52cc774476270d3302 @@ -7852,12 +7882,12 @@ __metadata: linkType: hard "postcss-selector-parser@npm:^7.0.0, postcss-selector-parser@npm:^7.1.1": - version: 7.1.1 - resolution: "postcss-selector-parser@npm:7.1.1" + version: 7.1.4 + resolution: "postcss-selector-parser@npm:7.1.4" dependencies: cssesc: "npm:^3.0.0" util-deprecate: "npm:^1.0.2" - checksum: 10/bb3c6455b20af26a556e3021e21101d8470252644e673c1612f7348ff8dd41b11321329f0694cf299b5b94863f823480b72d3e2f4bd3a89dc43e2d8c0dbad341 + checksum: 10/c6d36b37defd387d65b5e0b778cd441303d147eb4a4b7135c6a0452fa777310027218db3ff71a19fb831d067bcdf45a776c6ed71d83bd2ced8afc2e3d5b03385 languageName: node linkType: hard @@ -7918,12 +7948,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.8.3": - version: 3.8.3 - resolution: "prettier@npm:3.8.3" +"prettier@npm:^3.8.5": + version: 3.8.5 + resolution: "prettier@npm:3.8.5" bin: prettier: bin/prettier.cjs - checksum: 10/4b3b12cbb29e4c96bed936e5d070167552500c18d37676fb3e0caae6199c42860662608e4dc116230698f6e2bb0267ef2548158224c92d40f188d309d72fdd6f + checksum: 10/61b59be2f6fdb06850aa8add77a4c3ea31d9536ae25ee9206cdd7a52de968276270a69f4bb71a6e23cbe7bfe6705a74eebdfae2022659c4ccea246e45c290c94 languageName: node linkType: hard @@ -7939,10 +7969,10 @@ __metadata: languageName: node linkType: hard -"proc-log@npm:^6.0.0": - version: 6.1.0 - resolution: "proc-log@npm:6.1.0" - checksum: 10/9033f30f168ed5a0991b773d0c50ff88384c4738e9a0a67d341de36bf7293771eed648ab6a0562f62276da12fde91f3bbfc75ffff6e71ad49aafd74fc646be66 +"proc-log@npm:^7.0.0": + version: 7.0.0 + resolution: "proc-log@npm:7.0.0" + checksum: 10/97cd9f4a8a0d84e42ee91e106e5ba5edcb954521e8dbe26ee6ad31396e5c12cc2be5e5b6be7b53fa5a69959afbacd32719106e2d6f45802e34b31d9a3a01ec20 languageName: node linkType: hard @@ -7994,7 +8024,7 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^18.3.0": +"react-dom@npm:^18.3.1": version: 18.3.1 resolution: "react-dom@npm:18.3.1" dependencies: @@ -8014,9 +8044,9 @@ __metadata: linkType: hard "react-is-19@npm:react-is@^19.2.5": - version: 19.2.6 - resolution: "react-is@npm:19.2.6" - checksum: 10/5248eb5cfc135794c110f97fc0716a9439dc641d6bccbb8c00487c6c492644592ce4dd959fd23790bf05c1f26e0b62f11322b1e51372715a96a4bc4565a17d74 + version: 19.2.7 + resolution: "react-is@npm:19.2.7" + checksum: 10/ae0d3ae7638aa2fa2a82a78a817daf2806e57fa0aef357d07e1e8d1e9a301902e5967168e9334b5816db0df8fa980a725cd57569ba5a9e6e76a71e117b18be04 languageName: node linkType: hard @@ -8027,7 +8057,7 @@ __metadata: languageName: node linkType: hard -"react@npm:^18.3.0": +"react@npm:^18.3.1": version: 18.3.1 resolution: "react@npm:18.3.1" dependencies: @@ -8045,7 +8075,7 @@ __metadata: languageName: node linkType: hard -"reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": +"reflect.getprototypeof@npm:^1.0.10, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" dependencies: @@ -8113,13 +8143,20 @@ __metadata: linkType: hard "regjsparser@npm:^0.13.0": - version: 0.13.1 - resolution: "regjsparser@npm:0.13.1" + version: 0.13.2 + resolution: "regjsparser@npm:0.13.2" dependencies: jsesc: "npm:~3.1.0" bin: regjsparser: bin/parser - checksum: 10/3383e9dab8bc8cd09efcd9538191b1e194b1921438ca69fce833d1a447d0625635229464cbc6cb03f33e5d342f2d343e2738fdac9132e2470bca621e480c02ec + checksum: 10/291aecbd47371cee347a96c47ccaae729ba50b7b2cb2a5de7e088e2ab835fe133569422f06ae28f5ff0830ac03f3196a35ba493f23ecda086d82e3e326f14074 + languageName: node + linkType: hard + +"requestidlecallback@npm:^0.3.0": + version: 0.3.0 + resolution: "requestidlecallback@npm:0.3.0" + checksum: 10/a89c75f1eea347ccf30db1f1c48154df567ada16bc3b54c7ba969ae6cfb9a23d2c71121f8b05d9bc1303fcc3292aaaf7924f4fbd3cf33c8a4ed7c3095653e849 languageName: node linkType: hard @@ -8254,6 +8291,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:^5.1.1": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 + languageName: node + linkType: hard + "safe-push-apply@npm:^1.0.0": version: 1.0.0 resolution: "safe-push-apply@npm:1.0.0" @@ -8329,11 +8373,11 @@ __metadata: linkType: hard "semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3": - version: 7.8.1 - resolution: "semver@npm:7.8.1" + version: 7.8.5 + resolution: "semver@npm:7.8.5" bin: semver: bin/semver.js - checksum: 10/3244f6c4cb3f8126fea0426d353829ed4967e41e1f4696337c6fdcad87426466fe2badaf49d7dc85849acfc496ea0599432a4aecc33802d2d774e723acfa30e6 + checksum: 10/9b01d2ff11e6e4a4539b7ca3c5f280c8704cb397a28504469f2ed4f00ad2194748d756647362a9712fff30984d15772ab7f083108c2fb508e2096ae9e708f22c languageName: node linkType: hard @@ -8348,10 +8392,10 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:^7.0.5": - version: 7.0.5 - resolution: "serialize-javascript@npm:7.0.5" - checksum: 10/6237c76ef6df3d1ad61dd4a393b71ca758c7654f4d1cf77529e513134c0f0660302e03b7ec88a8f3a3daa79e1f93d6de8218ecbc45e073d7cc6b66284a1d3e83 +"serialize-javascript@npm:^7.0.6": + version: 7.0.6 + resolution: "serialize-javascript@npm:7.0.6" + checksum: 10/e3790f5dfd08fd842f2824c3b0c7893e2db55ed23e44293c95501d487522f6eea576fea69a353bf3cee8f30256825cb92b2219380b37693a68bad95c8b0edfbb languageName: node linkType: hard @@ -8417,7 +8461,7 @@ __metadata: languageName: node linkType: hard -"side-channel-list@npm:^1.0.0": +"side-channel-list@npm:^1.0.1": version: 1.0.1 resolution: "side-channel-list@npm:1.0.1" dependencies: @@ -8453,15 +8497,15 @@ __metadata: linkType: hard "side-channel@npm:^1.1.0": - version: 1.1.0 - resolution: "side-channel@npm:1.1.0" + version: 1.1.1 + resolution: "side-channel@npm:1.1.1" dependencies: es-errors: "npm:^1.3.0" - object-inspect: "npm:^1.13.3" - side-channel-list: "npm:^1.0.0" + object-inspect: "npm:^1.13.4" + side-channel-list: "npm:^1.0.1" side-channel-map: "npm:^1.0.1" side-channel-weakmap: "npm:^1.0.2" - checksum: 10/7d53b9db292c6262f326b6ff3bc1611db84ece36c2c7dc0e937954c13c73185b0406c56589e2bb8d071d6fee468e14c39fb5d203ee39be66b7b8174f179afaba + checksum: 10/5fa6393ff6ad25d8b4a38e9ba095481e498c8ebe5ab78481c1455146255a3d18ca37a6f936595cc671a6149134cdc295bbd2fa017620bdc73cbc7380634fa2fc languageName: node linkType: hard @@ -8655,29 +8699,30 @@ __metadata: linkType: hard "string.prototype.trim@npm:^1.2.10": - version: 1.2.10 - resolution: "string.prototype.trim@npm:1.2.10" + version: 1.2.11 + resolution: "string.prototype.trim@npm:1.2.11" dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.2" + call-bind: "npm:^1.0.9" + call-bound: "npm:^1.0.4" define-data-property: "npm:^1.1.4" define-properties: "npm:^1.2.1" - es-abstract: "npm:^1.23.5" - es-object-atoms: "npm:^1.0.0" + es-abstract: "npm:^1.24.2" + es-object-atoms: "npm:^1.1.2" has-property-descriptors: "npm:^1.0.2" - checksum: 10/47bb63cd2470a64bc5e2da1e570d369c016ccaa85c918c3a8bb4ab5965120f35e66d1f85ea544496fac84b9207a6b722adf007e6c548acd0813e5f8a82f9712a + safe-regex-test: "npm:^1.1.0" + checksum: 10/77c2301fe9f2f2e2085c2a9ab048f9f86b1b95609944e1f16d067186b7ac9121db2dd5bf8d165835891876d750ed325314e3181b8b6829d533f5214d472b3fc4 languageName: node linkType: hard "string.prototype.trimend@npm:^1.0.9": - version: 1.0.9 - resolution: "string.prototype.trimend@npm:1.0.9" + version: 1.0.10 + resolution: "string.prototype.trimend@npm:1.0.10" dependencies: - call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.2" + call-bind: "npm:^1.0.9" + call-bound: "npm:^1.0.4" define-properties: "npm:^1.2.1" - es-object-atoms: "npm:^1.0.0" - checksum: 10/140c73899b6747de9e499c7c2e7a83d549c47a26fa06045b69492be9cfb9e2a95187499a373983a08a115ecff8bc3bd7b0fb09b8ff72fb2172abe766849272ef + es-object-atoms: "npm:^1.1.2" + checksum: 10/f8a85346be853bbe34490c03f4c3f7adb0b4d5dedb206e4a48a006839fece0843fa97fe9c3222be5fd91ba33cdc7d495970af7a4707d15a62591555bfe5a5e20 languageName: node linkType: hard @@ -8799,12 +8844,21 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.11.12, synckit@npm:^0.11.8": - version: 0.11.12 - resolution: "synckit@npm:0.11.12" +"synckit@npm:^0.11.13, synckit@npm:^0.11.8": + version: 0.11.13 + resolution: "synckit@npm:0.11.13" + dependencies: + "@pkgr/core": "npm:^0.3.6" + checksum: 10/188544a829dbd38abd144281960835c1f8027aaa495b7c40d39ebf0c1c63a4a41c0edc42818b7ada9b5c0c15fba2f625b472220d128798fe98f9181fed84e6a2 + languageName: node + linkType: hard + +"tannin@npm:^1.2.0": + version: 1.2.0 + resolution: "tannin@npm:1.2.0" dependencies: - "@pkgr/core": "npm:^0.2.9" - checksum: 10/2f51978bfed81aaf0b093f596709a72c49b17909020f42b43c5549f9c0fe18b1fe29f82e41ef771172d729b32e9ce82900a85d2b87fa14d59f886d4df8d7a329 + "@tannin/plural-forms": "npm:^1.1.0" + checksum: 10/70e40d0dd00538e6f0c254f3c43bf980a442f52de5bc13766a60b3110ecdaa3e9447afd2f1b40fc7bbeea824ff1e7803016b60ca11660f18afdb4bb9111c7c68 languageName: node linkType: hard @@ -8815,22 +8869,22 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.5.13": - version: 7.5.15 - resolution: "tar@npm:7.5.15" +"tar@npm:^7.5.17": + version: 7.5.17 + resolution: "tar@npm:7.5.17" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10/b4cb6acd822159867f81ebda8d765c6941ec8292f1cf2f870d3713f4933c14bf0ed7bf4a92338143c31e8815ca0a1fdd62aa03ddb48a42ae187f7ef696583ffe + checksum: 10/89c289e29c5e81d7a166daf0856f6a9f6e28abb9d1053d047b227be2ce2eeee776664d20a62be53efdcabd6cb373517f12b9a960f23116fc13cbc490261b2d7e languageName: node linkType: hard -"terser-webpack-plugin@npm:^5.5.0, terser-webpack-plugin@npm:^5.6.0": - version: 5.6.0 - resolution: "terser-webpack-plugin@npm:5.6.0" +"terser-webpack-plugin@npm:^5.6.1": + version: 5.6.1 + resolution: "terser-webpack-plugin@npm:5.6.1" dependencies: "@jridgewell/trace-mapping": "npm:^0.3.25" jest-worker: "npm:^27.4.5" @@ -8863,7 +8917,7 @@ __metadata: optional: true uglify-js: optional: true - checksum: 10/15cae5c297146c6909a1c2daf2e3f4f6c1893416a3d19c388363bc963f1a3fd720803aceed44330bc52775434c85aa6df8d80f9524860568673195bafb600316 + checksum: 10/75e5ebb6759ccd85f34a5af04c2f1693f61d7691a4a1614f0892a1d2caf29c12395d1d04d4f4014d4c4a77b63f9f7f36ac9241fcbcc24085a7575d4a4310d70f languageName: node linkType: hard @@ -8893,12 +8947,12 @@ __metadata: linkType: hard "tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": - version: 0.2.16 - resolution: "tinyglobby@npm:0.2.16" + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" dependencies: fdir: "npm:^6.5.0" picomatch: "npm:^4.0.4" - checksum: 10/5c2c41b572ada38449e7c86a5fe034f204a1dbba577225a761a14f29f48dc3f2fc0d81a6c56fcc67c5a742cc3aa9fb5e2ca18dbf22b610b0bc0e549b34d5a0f8 + checksum: 10/f85e8a217d675c3f78d5f0ad25ea4557e7e023ed13ddc2b014da10bd0312eea53a34cd52356af07ccdff777f1243012547656282a4ca70936f68bf5065fbaa71 languageName: node linkType: hard @@ -9043,31 +9097,31 @@ __metadata: linkType: hard "typed-array-length@npm:^1.0.7": - version: 1.0.7 - resolution: "typed-array-length@npm:1.0.7" + version: 1.0.8 + resolution: "typed-array-length@npm:1.0.8" dependencies: - call-bind: "npm:^1.0.7" - for-each: "npm:^0.3.3" - gopd: "npm:^1.0.1" - is-typed-array: "npm:^1.1.13" - possible-typed-array-names: "npm:^1.0.0" - reflect.getprototypeof: "npm:^1.0.6" - checksum: 10/d6b2f0e81161682d2726eb92b1dc2b0890890f9930f33f9bcf6fc7272895ce66bc368066d273e6677776de167608adc53fcf81f1be39a146d64b630edbf2081c + call-bind: "npm:^1.0.9" + for-each: "npm:^0.3.5" + gopd: "npm:^1.2.0" + is-typed-array: "npm:^1.1.15" + possible-typed-array-names: "npm:^1.1.0" + reflect.getprototypeof: "npm:^1.0.10" + checksum: 10/c044c644eee13fe8814d4c2401146103efd49bfd1e40412104f04c4d67f76b373da7e93026fbd99aaef1fdaa9c81d0886f34772d045472be9704157f4e1d0164 languageName: node linkType: hard -"typescript-eslint@npm:^8.0.0": - version: 8.59.4 - resolution: "typescript-eslint@npm:8.59.4" +"typescript-eslint@npm:^8.57.1": + version: 8.62.0 + resolution: "typescript-eslint@npm:8.62.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.59.4" - "@typescript-eslint/parser": "npm:8.59.4" - "@typescript-eslint/typescript-estree": "npm:8.59.4" - "@typescript-eslint/utils": "npm:8.59.4" + "@typescript-eslint/eslint-plugin": "npm:8.62.0" + "@typescript-eslint/parser": "npm:8.62.0" + "@typescript-eslint/typescript-estree": "npm:8.62.0" + "@typescript-eslint/utils": "npm:8.62.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10/f065a4ae6abfad6af0a09de7052315cd8fa97a2e8ff0985aa8ad6ba0e059a662ef3b22751fbf58302de634b712a7d019f10f488d8e43b0510842a4f6460e57f4 + checksum: 10/c75b16115a3e6f7704f71a9fe14d8cf52129db7e0578755f10a344a0e22474cc0b5383822ef0344cd886b98300af4cce19a306ccda391b2e1eed6c07088f3019 languageName: node linkType: hard @@ -9103,17 +9157,17 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:>=7.24.0 <7.24.7": - version: 7.24.6 - resolution: "undici-types@npm:7.24.6" - checksum: 10/defc9538b952e3c15b8526596c591f7c1f0c7605ad27a2b7feddbea7ef2e3003f3eda2cdb051a3cb1a2185e3893100fd9cb925c799db99d48131ea63b5233d10 +"undici-types@npm:~8.3.0": + version: 8.3.0 + resolution: "undici-types@npm:8.3.0" + checksum: 10/6681d2837ac75a75ac1cc46090aa2b8ddc7c6b8ecc295a6cdb06838752a730da3d8afeecf05e5ab7903160eafad3a8b6ffa1927e5ded260590f4d4fef18646d5 languageName: node linkType: hard -"undici@npm:^7.24.2": - version: 7.25.0 - resolution: "undici@npm:7.25.0" - checksum: 10/038d3568c72bb976e3cc389284f7f1cc64cd70d578300e4676a449fbcb624a35fe99ac127b5f3729f18b8246d6c090444ab61b1b67736bb88f52a3e913d76bf8 +"undici@npm:^7.28.0": + version: 7.28.0 + resolution: "undici@npm:7.28.0" + checksum: 10/154423b280d623278a61decb437f8a7e581fb18b8c95556ef956b32a58cd668eadbb812d28e20678cb2dc545a566f35a3afc0962307ca801da30f4741117986d languageName: node linkType: hard @@ -9265,6 +9319,15 @@ __metadata: languageName: node linkType: hard +"use-memo-one@npm:^1.1.1": + version: 1.1.3 + resolution: "use-memo-one@npm:1.1.3" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10/8f08eba26d69406b61bb4b8dacdd5a92bd6aef5b53d346dfe87954f7330ee10ecabc937cc7854635155d46053828e85c10b5a5aff7a04720e6a97b9f42999bac + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.2": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -9272,19 +9335,6 @@ __metadata: languageName: node linkType: hard -"util@npm:^0.12.3": - version: 0.12.5 - resolution: "util@npm:0.12.5" - dependencies: - inherits: "npm:^2.0.3" - is-arguments: "npm:^1.0.4" - is-generator-function: "npm:^1.0.7" - is-typed-array: "npm:^1.1.3" - which-typed-array: "npm:^1.1.2" - checksum: 10/61a10de7753353dd4d744c917f74cdd7d21b8b46379c1e48e1c4fd8e83f8190e6bd9978fc4e5102ab6a10ebda6019d1b36572fa4a325e175ec8b789a121f6147 - languageName: node - linkType: hard - "v8-to-istanbul@npm:^9.0.1": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" @@ -9314,33 +9364,12 @@ __metadata: languageName: node linkType: hard -"watchpack@npm:^2.5.1": - version: 2.5.1 - resolution: "watchpack@npm:2.5.1" +"watchpack@npm:^2.5.2": + version: 2.5.2 + resolution: "watchpack@npm:2.5.2" dependencies: - glob-to-regexp: "npm:^0.4.1" graceful-fs: "npm:^4.1.2" - checksum: 10/9c9cdd4a9f9ae146b10d15387f383f52589e4cc27b324da6be8e7e3e755255b062a69dd7f00eef2ce67b2c01e546aae353456e74f8c1350bba00462cc6375549 - languageName: node - linkType: hard - -"web-encoding@npm:1.1.5": - version: 1.1.5 - resolution: "web-encoding@npm:1.1.5" - dependencies: - "@zxing/text-encoding": "npm:0.9.0" - util: "npm:^0.12.3" - dependenciesMeta: - "@zxing/text-encoding": - optional: true - checksum: 10/243518cfa8388ac05eeb4041bd330d38c599476ff9a93239b386d1ba2af130089a2fcefb0cf65b385f989105ff460ae69dca7e42236f4d98dc776b04e558cdb5 - languageName: node - linkType: hard - -"web-streams-polyfill@npm:^3.1.1": - version: 3.3.3 - resolution: "web-streams-polyfill@npm:3.3.3" - checksum: 10/8e7e13501b3834094a50abe7c0b6456155a55d7571312b89570012ef47ec2a46d766934768c50aabad10a9c30dd764a407623e8bfcc74fcb58495c29130edea9 + checksum: 10/203a41249b20521da0d86d9449510b66764440a189351eb3d2f84257bfddd4713ba984b7a1e1c1ef5938d987f7df37ee960ec0e2606a204d372f6366ea816e25 languageName: node linkType: hard @@ -9371,31 +9400,39 @@ __metadata: languageName: node linkType: hard -"webpack-cli@npm:^7.0.2": - version: 7.0.2 - resolution: "webpack-cli@npm:7.0.2" +"webpack-cli@npm:^7.1.0": + version: 7.1.0 + resolution: "webpack-cli@npm:7.1.0" dependencies: - "@discoveryjs/json-ext": "npm:^1.0.0" + "@discoveryjs/json-ext": "npm:^1.1.0" commander: "npm:^14.0.3" cross-spawn: "npm:^7.0.6" - envinfo: "npm:^7.14.0" - fastest-levenshtein: "npm:^1.0.12" - import-local: "npm:^3.0.2" + envinfo: "npm:^7.21.0" + import-local: "npm:^3.2.0" interpret: "npm:^3.1.1" rechoir: "npm:^0.8.0" webpack-merge: "npm:^6.0.1" peerDependencies: + js-yaml: ^4.0.0 || ^5.0.0 + json5: ^2.2.3 + toml: ^3.0.0 || ^4.0.0 webpack: ^5.101.0 webpack-bundle-analyzer: ^4.0.0 || ^5.0.0 webpack-dev-server: ^5.0.0 peerDependenciesMeta: + js-yaml: + optional: true + json5: + optional: true + toml: + optional: true webpack-bundle-analyzer: optional: true webpack-dev-server: optional: true bin: webpack-cli: bin/cli.js - checksum: 10/44ccbdccd1684d9e340e932680db62c237caa7b11f90799ae9a73aed5cf9033db32b8fae00a127ebe37ec5adafb4be5e5a3096b1ab52ae8ee51c2a445895695d + checksum: 10/74d404728b057e5887a822dc5abb41b825e2a819da26891ecb7eb125282d5041b99c8d0f90f3e910c128813ddfe5d5d946ce0106aab1fb7bbb236a94223f897a languageName: node linkType: hard @@ -9421,16 +9458,16 @@ __metadata: languageName: node linkType: hard -"webpack-sources@npm:^3.4.1": +"webpack-sources@npm:^3.5.0": version: 3.5.0 resolution: "webpack-sources@npm:3.5.0" checksum: 10/e5af4245dbe52bb1520c7c373d73042fe091e273ff94269b877725a7873481e544ba4e71722df13e278f88069de900052764d98b99f9910be634826ec2f6e67d languageName: node linkType: hard -"webpack@npm:^5.106.2": - version: 5.107.1 - resolution: "webpack@npm:5.107.1" +"webpack@npm:^5.108.1": + version: 5.108.1 + resolution: "webpack@npm:5.108.1" dependencies: "@types/estree": "npm:^1.0.8" "@types/json-schema": "npm:^7.0.15" @@ -9441,26 +9478,25 @@ __metadata: acorn-import-phases: "npm:^1.0.3" browserslist: "npm:^4.28.1" chrome-trace-event: "npm:^1.0.2" - enhanced-resolve: "npm:^5.21.4" + enhanced-resolve: "npm:^5.22.2" es-module-lexer: "npm:^2.1.0" eslint-scope: "npm:5.1.1" events: "npm:^3.2.0" - glob-to-regexp: "npm:^0.4.1" graceful-fs: "npm:^4.2.11" loader-runner: "npm:^4.3.2" mime-db: "npm:^1.54.0" + minimizer-webpack-plugin: "npm:^5.6.1" neo-async: "npm:^2.6.2" schema-utils: "npm:^4.3.3" tapable: "npm:^2.3.0" - terser-webpack-plugin: "npm:^5.5.0" - watchpack: "npm:^2.5.1" - webpack-sources: "npm:^3.4.1" + watchpack: "npm:^2.5.2" + webpack-sources: "npm:^3.5.0" peerDependenciesMeta: webpack-cli: optional: true bin: webpack: bin/webpack.js - checksum: 10/195722f146380ca795207325dd729508d25fddf59dc9f6f7b31f880f652d7a2fb8126c2ac1666f1df1c01f8720527a07950885363d8774664735a35bc35a841e + checksum: 10/d816c66353702e0d117c1e80812f48e1c516f683dae745113437ffe9c189d23fc232d1396786ce427bd5282cdef090e5e99815c58a38393df2df0ad4641d4822 languageName: node linkType: hard @@ -9546,18 +9582,18 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": - version: 1.1.20 - resolution: "which-typed-array@npm:1.1.20" +"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19": + version: 1.1.22 + resolution: "which-typed-array@npm:1.1.22" dependencies: available-typed-arrays: "npm:^1.0.7" - call-bind: "npm:^1.0.8" + call-bind: "npm:^1.0.9" call-bound: "npm:^1.0.4" for-each: "npm:^0.3.5" get-proto: "npm:^1.0.1" gopd: "npm:^1.2.0" has-tostringtag: "npm:^1.0.2" - checksum: 10/e56da3fc995d330ff012f682476f7883c16b12d36c6717c87c7ca23eb5a5ef957fa89115dacb389b11a9b4e99d5dbe2d12689b4d5d08c050b5aed0eae385b840 + checksum: 10/59b0383347e2f3b0bc5be570c2dfae551b172a9c83e0a6b03c6e17401d6161dfa1d912c7657062fe9add254a0d3c25ef70593dbaec8fefa8714715ff69e0a3fc languageName: node linkType: hard @@ -9572,14 +9608,14 @@ __metadata: languageName: node linkType: hard -"which@npm:^6.0.0": - version: 6.0.1 - resolution: "which@npm:6.0.1" +"which@npm:^7.0.0": + version: 7.0.0 + resolution: "which@npm:7.0.0" dependencies: isexe: "npm:^4.0.0" bin: node-which: bin/which.js - checksum: 10/dbea77c7d3058bf6c78bf9659d2dce4d2b57d39a15b826b2af6ac2e5a219b99dc8a831b79fdbc453c0598adb4f3f84cf9c2491fd52beb9f5d2dececcad117f68 + checksum: 10/913a43ac10df37602ba9795a004dd7ab12ba7dd592aca1f08ec333be1fdd6a49bbf119a88c3f8d0ea70eeb6251726e77069251424d73000299a0a840ed000732 languageName: node linkType: hard @@ -9701,8 +9737,8 @@ __metadata: linkType: hard "yargs@npm:^17.7.2": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" + version: 17.7.3 + resolution: "yargs@npm:17.7.3" dependencies: cliui: "npm:^8.0.1" escalade: "npm:^3.1.1" @@ -9711,7 +9747,7 @@ __metadata: string-width: "npm:^4.2.3" y18n: "npm:^5.0.5" yargs-parser: "npm:^21.1.1" - checksum: 10/abb3e37678d6e38ea85485ed86ebe0d1e3464c640d7d9069805ea0da12f69d5a32df8e5625e370f9c96dd1c2dc088ab2d0a4dd32af18222ef3c4224a19471576 + checksum: 10/a3826798c03b159e139d0580a3b2733953889a9a1bac8e4e1ca7a1a249b55315b213c323a6a1dbdb305f6e59496a9eaa810742c87e34abcf1a0584d8f59212a1 languageName: node linkType: hard