diff --git a/.gitignore b/.gitignore
index 6b6a5c5..e1192ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
/node_modules/
/build/
/.phpunit.result.cache
+/.wp-env.override.json
.DS_Store
diff --git a/.wp-env.json b/.wp-env.json
new file mode 100644
index 0000000..d10e4fd
--- /dev/null
+++ b/.wp-env.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/wp-env.json",
+ "phpVersion": "8.2",
+ "plugins": [
+ ".",
+ "../gravityforms"
+ ],
+ "config": {
+ "WP_TESTS_DOMAIN": "localhost",
+ "WP_TESTS_EMAIL": "test@genero.fi",
+ "WP_TESTS_TITLE": "ALTCHA for Gravity Forms Tests",
+ "WP_PHP_BINARY": "php"
+ }
+}
diff --git a/README.md b/README.md
index 7bcf459..02e4b16 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,46 @@ the `gravityformsaddon_gravityforms-altcha_settings` option, the per-form one in
the form meta). The `genero/gravityforms_altcha/should_protect` filter can still
override the saved settings programmatically.
+### Protection strength
+
+Forms → Settings → **ALTCHA** has a *Protection strength* dropdown (Low /
+Standard / High / Very high) controlling how hard the background proof-of-work
+is — stronger costs bots more per submission but takes longer to solve on
+low-end devices. Because the widget solves on page load while the form is being
+filled, even the higher settings are normally invisible. Each form's ALTCHA tab
+can override the site-wide strength or inherit it. For an exact custom value,
+use the `genero/gravityforms_altcha/cost` filter.
+
+### Extra spam layers (optional)
+
+Two independent, fully first-party layers can be toggled on under Forms →
+Settings → **ALTCHA**. Both **mark suspicious submissions as spam** (via
+`gform_entry_is_spam`) rather than rejecting them — a real visitor is never
+blocked or shown an error, and a false positive stays recoverable in the entry
+*Spam* view. They apply to every Gravity Form, independent of whether ALTCHA
+itself is enabled.
+
+* **Rate limiting** — flags submissions once an IP exceeds a per-form
+ allowance within a window (default 3 per hour). The IP is resolved independently of Gravity
+ Forms (sites often blank GF's stored IP for GDPR) and kept only as a salted
+ HMAC in a 60-second transient — the raw IP is never stored or logged. Behind
+ a CDN/proxy, point it at the right client-IP header (see
+ `genero/gravityforms_altcha/client_ip_headers`).
+* **Content spam filtering** — flags submissions whose text contains a
+ definite-spam keyword (matched on word boundaries), or accumulates enough
+ weaker signals (link farms, a URL in the name field, injected markup,
+ wrong-script text). Scans all visitor text including composite name/address
+ fields, and ignores zero-width characters used to evade matching.
+* **Email validation** — unlike the two above, this *blocks* the field (with a
+ corrective message) so the visitor fixes a bad address rather than silently
+ never hearing back. Per-verdict checkboxes decide what to reject —
+ **undeliverable** (default on), **risky** (default off — may catch some real
+ catch-all/role addresses), and **disposable** (default on). Verifies via
+ [Bouncer](https://usebouncer.com); requires the `BOUNCER_API_KEY` environment
+ variable. **Fails open** — `unknown`, a missing key, or an API error never
+ block. Note: this sends the submitted email to a third-party service, so
+ cover it in your privacy policy / DPA.
+
## How it works
1. **Form render** — when enabled for the form, the plugin injects a hidden
@@ -61,6 +101,10 @@ override the saved settings programmatically.
4. **Server-side verification** — `gform_validation` decodes the payload,
reconstructs the challenge, and runs `altcha-org/altcha::verifySolution()`.
On failure the submission is rejected with a generic error message.
+5. **Replay protection** — each challenge is single-use. A solved payload's
+ unique signature is remembered for the rest of its lifetime, so the same
+ proof can't be replayed across many submissions — a bot must solve a fresh
+ challenge every time rather than paying the cost once.
Day-to-day configuration lives in the admin UI (see [Settings](#settings)); the
filters below cover advanced overrides.
@@ -102,16 +146,102 @@ Localise or rewrite the validation error:
add_filter('genero/gravityforms_altcha/error_message', fn () => __('Spam check failed. Please reload and try again.', 'your-textdomain'));
```
+### `genero/gravityforms_altcha/cost`
+
+Set an exact proof-of-work cost (PBKDF2 iterations), overriding the
+*Protection strength* dropdown. Receives the form id for context:
+
+```php
+add_filter('genero/gravityforms_altcha/cost', fn (int $cost, ?int $formId) => 750000, 10, 2);
+```
+
+### `genero/gravityforms_altcha/client_ip_headers`
+
+Only relevant when *Rate limiting* is on. Ordered list of `$_SERVER` keys to
+read the client IP from; defaults to `['REMOTE_ADDR']`. Behind a CDN, **prepend
+the single header your CDN sets** — never trust a forwarded header it doesn't,
+as it can be spoofed to evade the limit or push a real visitor over it.
+
+```php
+add_filter('genero/gravityforms_altcha/client_ip_headers', fn () => [
+ 'HTTP_CF_CONNECTING_IP', // Cloudflare
+ 'REMOTE_ADDR',
+]);
+```
+
+Common headers: `HTTP_CF_CONNECTING_IP` (Cloudflare), `HTTP_FASTLY_CLIENT_IP`
+(Fastly), `HTTP_TRUE_CLIENT_IP` (Akamai), `HTTP_X_REAL_IP` (nginx).
+
+### `genero/gravityforms_altcha/rate_limit_max` and `…/rate_limit_window`
+
+Per-IP, per-form submission allowance and the window (seconds) it applies over
+before a submission is flagged as spam. Default: 3 per hour. For "1 per minute":
+
+```php
+add_filter('genero/gravityforms_altcha/rate_limit_max', fn () => 1);
+add_filter('genero/gravityforms_altcha/rate_limit_window', fn () => MINUTE_IN_SECONDS);
+```
+
+### `genero/gravityforms_altcha/spam_keywords` and `…/spam_score_threshold`
+
+Tune the content filter — definite-spam keywords (a single match flags) and the
+score the weaker heuristics must reach (each signal contributes 2; default 3):
+
+```php
+add_filter('genero/gravityforms_altcha/spam_keywords', fn (array $words) => [...$words, 'crypto']);
+add_filter('genero/gravityforms_altcha/spam_score_threshold', fn () => 4);
+```
+
+### `genero/gravityforms_altcha/bouncer_api_key`
+
+Provide the Bouncer key in code instead of the `BOUNCER_API_KEY` env var (e.g.
+from a secrets manager):
+
+```php
+add_filter('genero/gravityforms_altcha/bouncer_api_key', fn () => get_option('my_bouncer_key'));
+```
+
+### `genero/gravityforms_altcha/email_should_validate` and `…/email_error_message`
+
+Skip email validation for specific fields/forms, or customise the rejection
+message:
+
+```php
+add_filter('genero/gravityforms_altcha/email_should_validate', fn (bool $v, $field, $form) => (int) $form['id'] !== 12, 10, 3);
+add_filter('genero/gravityforms_altcha/email_error_message', fn () => __('That email looks undeliverable — please check it.', 'your-textdomain'));
+```
+
## Development
```bash
composer install
npm install
npm run build # outputs build/widget.js
-composer test # PHPUnit suite, no WordPress dependency
composer lint:fix # Pint
```
+## Tests
+
+Two layers:
+
+* **`composer test`** — the `unit` (pure logic) and `mocked` (WP functions stubbed
+ with Brain\Monkey) suites. No WordPress, no database — runs in CI on PHP
+ 8.2–8.4.
+* **`composer test:integration`** — real-WordPress integration tests
+ (`wp-phpunit`) covering the settings, the `gform_entry_is_spam` spam layers,
+ and cost resolution against an actual Gravity Forms install. Gravity Forms is
+ commercial, so these **skip when it isn't present** (e.g. CI) and run for real
+ via wp-env or DDEV.
+
+Run the integration suite with [`wp-env`](https://www.npmjs.com/package/@wordpress/env)
+(place a `gravityforms` checkout alongside this repo so it mounts):
+
+```bash
+npx @wordpress/env start
+npx @wordpress/env run tests-cli --env-cwd=wp-content/plugins/gravityforms-altcha \
+ vendor/bin/phpunit -c phpunit.integration.xml.dist
+```
+
## License
MIT — see [`LICENSE`](./LICENSE).
diff --git a/composer.json b/composer.json
index 8f14e59..bac067e 100644
--- a/composer.json
+++ b/composer.json
@@ -16,7 +16,10 @@
},
"require-dev": {
"laravel/pint": "^1.17",
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.0",
+ "brain/monkey": "^2.6",
+ "wp-phpunit/wp-phpunit": "^7.0",
+ "yoast/phpunit-polyfills": "^2.0"
},
"autoload": {
"psr-4": {
@@ -25,12 +28,23 @@
},
"autoload-dev": {
"psr-4": {
- "Genero\\GravityFormsAltcha\\Tests\\": "test/unit/"
+ "Genero\\GravityFormsAltcha\\Tests\\": "test/unit/",
+ "Genero\\GravityFormsAltcha\\Tests\\Mocked\\": "test/mocked/",
+ "Genero\\GravityFormsAltcha\\Tests\\Integration\\": "test/integration/"
}
},
"scripts": {
"lint": "pint --test",
"lint:fix": "pint",
- "test": "phpunit"
+ "test": "phpunit",
+ "test:integration": "phpunit -c phpunit.integration.xml.dist"
+ },
+ "config": {
+ "platform": {
+ "php": "8.2"
+ },
+ "allow-plugins": {
+ "composer/installers": true
+ }
}
}
diff --git a/composer.lock b/composer.lock
index 8a8acb0..5823033 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "6145b5906f75be8a7214ac75095c26c5",
+ "content-hash": "1fa79ad8ee2d82ba9b0044069fe8e872",
"packages": [
{
"name": "altcha-org/altcha",
@@ -55,6 +55,245 @@
}
],
"packages-dev": [
+ {
+ "name": "antecedent/patchwork",
+ "version": "2.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/antecedent/patchwork.git",
+ "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce",
+ "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": ">=4"
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignas Rudaitis",
+ "email": "ignas.rudaitis@gmail.com"
+ }
+ ],
+ "description": "Method redefinition (monkey-patching) functionality for PHP.",
+ "homepage": "https://antecedent.github.io/patchwork/",
+ "keywords": [
+ "aop",
+ "aspect",
+ "interception",
+ "monkeypatching",
+ "redefinition",
+ "runkit",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/antecedent/patchwork/issues",
+ "source": "https://github.com/antecedent/patchwork/tree/2.2.3"
+ },
+ "time": "2025-09-17T09:00:56+00:00"
+ },
+ {
+ "name": "brain/monkey",
+ "version": "2.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Brain-WP/BrainMonkey.git",
+ "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83",
+ "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83",
+ "shasum": ""
+ },
+ "require": {
+ "antecedent/patchwork": "^2.1.17",
+ "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10",
+ "php": ">=5.6.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
+ "phpcompatibility/php-compatibility": "^9.3.0",
+ "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev",
+ "dev-version/1": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "inc/api.php"
+ ],
+ "psr-4": {
+ "Brain\\Monkey\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Giuseppe Mazzapica",
+ "email": "giuseppe.mazzapica@gmail.com",
+ "homepage": "https://gmazzap.me",
+ "role": "Developer"
+ }
+ ],
+ "description": "Mocking utility for PHP functions and WordPress plugin API",
+ "keywords": [
+ "Monkey Patching",
+ "interception",
+ "mock",
+ "mock functions",
+ "mockery",
+ "patchwork",
+ "redefinition",
+ "runkit",
+ "test",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/Brain-WP/BrainMonkey/issues",
+ "source": "https://github.com/Brain-WP/BrainMonkey"
+ },
+ "time": "2026-02-05T09:22:14+00:00"
+ },
+ {
+ "name": "doctrine/instantiator",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0",
+ "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^11",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/phpstan": "^1.9.4",
+ "phpstan/phpstan-phpunit": "^1.3",
+ "phpunit/phpunit": "^9.5.27",
+ "vimeo/psalm": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "https://ocramius.github.io/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-12-30T00:23:10+00:00"
+ },
+ {
+ "name": "hamcrest/hamcrest-php",
+ "version": "v2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/hamcrest/hamcrest-php.git",
+ "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
+ "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0"
+ },
+ "replace": {
+ "cordoval/hamcrest-php": "*",
+ "davedevelopment/hamcrest-php": "*",
+ "kodova/hamcrest-php": "*"
+ },
+ "require-dev": {
+ "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
+ "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "hamcrest"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "This is the PHP port of Hamcrest Matchers",
+ "keywords": [
+ "test"
+ ],
+ "support": {
+ "issues": "https://github.com/hamcrest/hamcrest-php/issues",
+ "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1"
+ },
+ "time": "2025-04-30T06:54:44+00:00"
+ },
{
"name": "laravel/pint",
"version": "v1.29.1",
@@ -123,6 +362,89 @@
},
"time": "2026-04-20T15:26:14+00:00"
},
+ {
+ "name": "mockery/mockery",
+ "version": "1.6.12",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mockery/mockery.git",
+ "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "shasum": ""
+ },
+ "require": {
+ "hamcrest/hamcrest-php": "^2.0.1",
+ "lib-pcre": ">=7.0",
+ "php": ">=7.3"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.6.17",
+ "symplify/easy-coding-standard": "^12.1.14"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/helpers.php",
+ "library/Mockery.php"
+ ],
+ "psr-4": {
+ "Mockery\\": "library/Mockery"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Pádraic Brady",
+ "email": "padraic.brady@gmail.com",
+ "homepage": "https://github.com/padraic",
+ "role": "Author"
+ },
+ {
+ "name": "Dave Marshall",
+ "email": "dave.marshall@atstsolutions.co.uk",
+ "homepage": "https://davedevelopment.co.uk",
+ "role": "Developer"
+ },
+ {
+ "name": "Nathanael Esayeas",
+ "email": "nathanael.esayeas@protonmail.com",
+ "homepage": "https://github.com/ghostwriter",
+ "role": "Lead Developer"
+ }
+ ],
+ "description": "Mockery is a simple yet flexible PHP mock object framework",
+ "homepage": "https://github.com/mockery/mockery",
+ "keywords": [
+ "BDD",
+ "TDD",
+ "library",
+ "mock",
+ "mock objects",
+ "mockery",
+ "stub",
+ "test",
+ "test double",
+ "testing"
+ ],
+ "support": {
+ "docs": "https://docs.mockery.io/",
+ "issues": "https://github.com/mockery/mockery/issues",
+ "rss": "https://github.com/mockery/mockery/releases.atom",
+ "security": "https://github.com/mockery/mockery/security/advisories",
+ "source": "https://github.com/mockery/mockery"
+ },
+ "time": "2024-05-16T03:13:13+00:00"
+ },
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
@@ -361,16 +683,16 @@
},
{
"name": "phpunit/php-code-coverage",
- "version": "10.1.16",
+ "version": "9.2.32",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "7e308268858ed6baedc8704a304727d20bc07c77"
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77",
- "reference": "7e308268858ed6baedc8704a304727d20bc07c77",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
"shasum": ""
},
"require": {
@@ -378,18 +700,18 @@
"ext-libxml": "*",
"ext-xmlwriter": "*",
"nikic/php-parser": "^4.19.1 || ^5.1.0",
- "php": ">=8.1",
- "phpunit/php-file-iterator": "^4.1.0",
- "phpunit/php-text-template": "^3.0.1",
- "sebastian/code-unit-reverse-lookup": "^3.0.0",
- "sebastian/complexity": "^3.2.0",
- "sebastian/environment": "^6.1.0",
- "sebastian/lines-of-code": "^2.0.2",
- "sebastian/version": "^4.0.1",
+ "php": ">=7.3",
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-text-template": "^2.0.4",
+ "sebastian/code-unit-reverse-lookup": "^2.0.3",
+ "sebastian/complexity": "^2.0.3",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/lines-of-code": "^1.0.4",
+ "sebastian/version": "^3.0.2",
"theseer/tokenizer": "^1.2.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.1"
+ "phpunit/phpunit": "^9.6"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -398,7 +720,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "10.1.x-dev"
+ "dev-main": "9.2.x-dev"
}
},
"autoload": {
@@ -427,7 +749,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32"
},
"funding": [
{
@@ -435,32 +757,32 @@
"type": "github"
}
],
- "time": "2024-08-22T04:31:57+00:00"
+ "time": "2024-08-22T04:23:01+00:00"
},
{
"name": "phpunit/php-file-iterator",
- "version": "4.1.0",
+ "version": "3.0.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
- "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c"
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c",
- "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "4.0-dev"
+ "dev-master": "3.0-dev"
}
},
"autoload": {
@@ -487,8 +809,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
- "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
- "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0"
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
},
"funding": [
{
@@ -496,28 +817,28 @@
"type": "github"
}
],
- "time": "2023-08-31T06:24:48+00:00"
+ "time": "2021-12-02T12:48:52+00:00"
},
{
"name": "phpunit/php-invoker",
- "version": "4.0.0",
+ "version": "3.1.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-invoker.git",
- "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7"
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
- "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
"ext-pcntl": "*",
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"suggest": {
"ext-pcntl": "*"
@@ -525,7 +846,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "4.0-dev"
+ "dev-master": "3.1-dev"
}
},
"autoload": {
@@ -551,7 +872,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-invoker/issues",
- "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0"
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
},
"funding": [
{
@@ -559,32 +880,32 @@
"type": "github"
}
],
- "time": "2023-02-03T06:56:09+00:00"
+ "time": "2020-09-28T05:58:55+00:00"
},
{
"name": "phpunit/php-text-template",
- "version": "3.0.1",
+ "version": "2.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-text-template.git",
- "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748"
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748",
- "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "3.0-dev"
+ "dev-master": "2.0-dev"
}
},
"autoload": {
@@ -610,8 +931,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-text-template/issues",
- "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
- "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1"
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
},
"funding": [
{
@@ -619,32 +939,32 @@
"type": "github"
}
],
- "time": "2023-08-31T14:07:24+00:00"
+ "time": "2020-10-26T05:33:50+00:00"
},
{
"name": "phpunit/php-timer",
- "version": "6.0.0",
+ "version": "5.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-timer.git",
- "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d"
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d",
- "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.0-dev"
+ "dev-master": "5.0-dev"
}
},
"autoload": {
@@ -670,7 +990,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-timer/issues",
- "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0"
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
},
"funding": [
{
@@ -678,23 +998,24 @@
"type": "github"
}
],
- "time": "2023-02-03T06:57:52+00:00"
+ "time": "2020-10-26T13:16:10+00:00"
},
{
"name": "phpunit/phpunit",
- "version": "10.5.63",
+ "version": "9.6.34",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "33198268dad71e926626b618f3ec3966661e4d90"
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
- "reference": "33198268dad71e926626b618f3ec3966661e4d90",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a",
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a",
"shasum": ""
},
"require": {
+ "doctrine/instantiator": "^1.5.0 || ^2",
"ext-dom": "*",
"ext-json": "*",
"ext-libxml": "*",
@@ -704,26 +1025,27 @@
"myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
- "php": ">=8.1",
- "phpunit/php-code-coverage": "^10.1.16",
- "phpunit/php-file-iterator": "^4.1.0",
- "phpunit/php-invoker": "^4.0.0",
- "phpunit/php-text-template": "^3.0.1",
- "phpunit/php-timer": "^6.0.0",
- "sebastian/cli-parser": "^2.0.1",
- "sebastian/code-unit": "^2.0.0",
- "sebastian/comparator": "^5.0.5",
- "sebastian/diff": "^5.1.1",
- "sebastian/environment": "^6.1.0",
- "sebastian/exporter": "^5.1.4",
- "sebastian/global-state": "^6.0.2",
- "sebastian/object-enumerator": "^5.0.0",
- "sebastian/recursion-context": "^5.0.1",
- "sebastian/type": "^4.0.0",
- "sebastian/version": "^4.0.1"
+ "php": ">=7.3",
+ "phpunit/php-code-coverage": "^9.2.32",
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-invoker": "^3.1.1",
+ "phpunit/php-text-template": "^2.0.4",
+ "phpunit/php-timer": "^5.0.3",
+ "sebastian/cli-parser": "^1.0.2",
+ "sebastian/code-unit": "^1.0.8",
+ "sebastian/comparator": "^4.0.10",
+ "sebastian/diff": "^4.0.6",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/exporter": "^4.0.8",
+ "sebastian/global-state": "^5.0.8",
+ "sebastian/object-enumerator": "^4.0.4",
+ "sebastian/resource-operations": "^3.0.4",
+ "sebastian/type": "^3.2.1",
+ "sebastian/version": "^3.0.2"
},
"suggest": {
- "ext-soap": "To be able to generate mocks based on WSDL files"
+ "ext-soap": "To be able to generate mocks based on WSDL files",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
},
"bin": [
"phpunit"
@@ -731,7 +1053,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "10.5-dev"
+ "dev-master": "9.6-dev"
}
},
"autoload": {
@@ -763,7 +1085,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34"
},
"funding": [
{
@@ -787,32 +1109,32 @@
"type": "tidelift"
}
],
- "time": "2026-01-27T05:48:37+00:00"
+ "time": "2026-01-27T05:45:00+00:00"
},
{
"name": "sebastian/cli-parser",
- "version": "2.0.1",
+ "version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/cli-parser.git",
- "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084"
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084",
- "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.0-dev"
+ "dev-master": "1.0-dev"
}
},
"autoload": {
@@ -835,8 +1157,7 @@
"homepage": "https://github.com/sebastianbergmann/cli-parser",
"support": {
"issues": "https://github.com/sebastianbergmann/cli-parser/issues",
- "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
- "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1"
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
},
"funding": [
{
@@ -844,32 +1165,32 @@
"type": "github"
}
],
- "time": "2024-03-02T07:12:49+00:00"
+ "time": "2024-03-02T06:27:43+00:00"
},
{
"name": "sebastian/code-unit",
- "version": "2.0.0",
+ "version": "1.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/code-unit.git",
- "reference": "a81fee9eef0b7a76af11d121767abc44c104e503"
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503",
- "reference": "a81fee9eef0b7a76af11d121767abc44c104e503",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.0-dev"
+ "dev-master": "1.0-dev"
}
},
"autoload": {
@@ -892,7 +1213,7 @@
"homepage": "https://github.com/sebastianbergmann/code-unit",
"support": {
"issues": "https://github.com/sebastianbergmann/code-unit/issues",
- "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0"
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
},
"funding": [
{
@@ -900,32 +1221,32 @@
"type": "github"
}
],
- "time": "2023-02-03T06:58:43+00:00"
+ "time": "2020-10-26T13:08:54+00:00"
},
{
"name": "sebastian/code-unit-reverse-lookup",
- "version": "3.0.0",
+ "version": "2.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
- "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d"
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
- "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "3.0-dev"
+ "dev-master": "2.0-dev"
}
},
"autoload": {
@@ -947,7 +1268,7 @@
"homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
"support": {
"issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
- "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0"
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
},
"funding": [
{
@@ -955,36 +1276,34 @@
"type": "github"
}
],
- "time": "2023-02-03T06:59:15+00:00"
+ "time": "2020-09-28T05:30:19+00:00"
},
{
"name": "sebastian/comparator",
- "version": "5.0.5",
+ "version": "4.0.10",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
- "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
"shasum": ""
},
"require": {
- "ext-dom": "*",
- "ext-mbstring": "*",
- "php": ">=8.1",
- "sebastian/diff": "^5.0",
- "sebastian/exporter": "^5.0"
+ "php": ">=7.3",
+ "sebastian/diff": "^4.0",
+ "sebastian/exporter": "^4.0"
},
"require-dev": {
- "phpunit/phpunit": "^10.5"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.0-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -1023,8 +1342,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
- "security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
},
"funding": [
{
@@ -1044,33 +1362,33 @@
"type": "tidelift"
}
],
- "time": "2026-01-24T09:25:16+00:00"
+ "time": "2026-01-24T09:22:56+00:00"
},
{
"name": "sebastian/complexity",
- "version": "3.2.0",
+ "version": "2.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/complexity.git",
- "reference": "68ff824baeae169ec9f2137158ee529584553799"
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799",
- "reference": "68ff824baeae169ec9f2137158ee529584553799",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
"shasum": ""
},
"require": {
"nikic/php-parser": "^4.18 || ^5.0",
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "3.2-dev"
+ "dev-master": "2.0-dev"
}
},
"autoload": {
@@ -1093,8 +1411,7 @@
"homepage": "https://github.com/sebastianbergmann/complexity",
"support": {
"issues": "https://github.com/sebastianbergmann/complexity/issues",
- "security": "https://github.com/sebastianbergmann/complexity/security/policy",
- "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0"
+ "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
},
"funding": [
{
@@ -1102,33 +1419,33 @@
"type": "github"
}
],
- "time": "2023-12-21T08:37:17+00:00"
+ "time": "2023-12-22T06:19:30+00:00"
},
{
"name": "sebastian/diff",
- "version": "5.1.1",
+ "version": "4.0.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e"
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e",
- "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0",
- "symfony/process": "^6.4"
+ "phpunit/phpunit": "^9.3",
+ "symfony/process": "^4.2 || ^5"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.1-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -1160,8 +1477,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
- "security": "https://github.com/sebastianbergmann/diff/security/policy",
- "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1"
+ "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
},
"funding": [
{
@@ -1169,27 +1485,27 @@
"type": "github"
}
],
- "time": "2024-03-02T07:15:17+00:00"
+ "time": "2024-03-02T06:30:58+00:00"
},
{
"name": "sebastian/environment",
- "version": "6.1.0",
+ "version": "5.1.5",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "8074dbcd93529b357029f5cc5058fd3e43666984"
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984",
- "reference": "8074dbcd93529b357029f5cc5058fd3e43666984",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"suggest": {
"ext-posix": "*"
@@ -1197,7 +1513,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.1-dev"
+ "dev-master": "5.1-dev"
}
},
"autoload": {
@@ -1216,7 +1532,7 @@
}
],
"description": "Provides functionality to handle HHVM/PHP environments",
- "homepage": "https://github.com/sebastianbergmann/environment",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
"keywords": [
"Xdebug",
"environment",
@@ -1224,8 +1540,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
- "security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0"
+ "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5"
},
"funding": [
{
@@ -1233,34 +1548,34 @@
"type": "github"
}
],
- "time": "2024-03-23T08:47:14+00:00"
+ "time": "2023-02-03T06:03:51+00:00"
},
{
"name": "sebastian/exporter",
- "version": "5.1.4",
+ "version": "4.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "0735b90f4da94969541dac1da743446e276defa6"
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6",
- "reference": "0735b90f4da94969541dac1da743446e276defa6",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
"shasum": ""
},
"require": {
- "ext-mbstring": "*",
- "php": ">=8.1",
- "sebastian/recursion-context": "^5.0"
+ "php": ">=7.3",
+ "sebastian/recursion-context": "^4.0"
},
"require-dev": {
- "phpunit/phpunit": "^10.5"
+ "ext-mbstring": "*",
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.1-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -1302,8 +1617,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
- "security": "https://github.com/sebastianbergmann/exporter/security/policy",
- "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4"
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
},
"funding": [
{
@@ -1323,35 +1637,38 @@
"type": "tidelift"
}
],
- "time": "2025-09-24T06:09:11+00:00"
+ "time": "2025-09-24T06:03:27+00:00"
},
{
"name": "sebastian/global-state",
- "version": "6.0.2",
+ "version": "5.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9"
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
- "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
"shasum": ""
},
"require": {
- "php": ">=8.1",
- "sebastian/object-reflector": "^3.0",
- "sebastian/recursion-context": "^5.0"
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
},
"require-dev": {
"ext-dom": "*",
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-uopz": "*"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.0-dev"
+ "dev-master": "5.0-dev"
}
},
"autoload": {
@@ -1370,48 +1687,59 @@
}
],
"description": "Snapshotting of global state",
- "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
"keywords": [
"global state"
],
"support": {
"issues": "https://github.com/sebastianbergmann/global-state/issues",
- "security": "https://github.com/sebastianbergmann/global-state/security/policy",
- "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2"
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
}
],
- "time": "2024-03-02T07:19:19+00:00"
+ "time": "2025-08-10T07:10:35+00:00"
},
{
"name": "sebastian/lines-of-code",
- "version": "2.0.2",
+ "version": "1.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
- "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0"
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0",
- "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
"shasum": ""
},
"require": {
"nikic/php-parser": "^4.18 || ^5.0",
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.0-dev"
+ "dev-master": "1.0-dev"
}
},
"autoload": {
@@ -1434,8 +1762,7 @@
"homepage": "https://github.com/sebastianbergmann/lines-of-code",
"support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
- "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
- "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2"
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
},
"funding": [
{
@@ -1443,34 +1770,34 @@
"type": "github"
}
],
- "time": "2023-12-21T08:38:20+00:00"
+ "time": "2023-12-22T06:20:34+00:00"
},
{
"name": "sebastian/object-enumerator",
- "version": "5.0.0",
+ "version": "4.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-enumerator.git",
- "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906"
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906",
- "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
"shasum": ""
},
"require": {
- "php": ">=8.1",
- "sebastian/object-reflector": "^3.0",
- "sebastian/recursion-context": "^5.0"
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.0-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -1492,7 +1819,7 @@
"homepage": "https://github.com/sebastianbergmann/object-enumerator/",
"support": {
"issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
- "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0"
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
},
"funding": [
{
@@ -1500,32 +1827,32 @@
"type": "github"
}
],
- "time": "2023-02-03T07:08:32+00:00"
+ "time": "2020-10-26T13:12:34+00:00"
},
{
"name": "sebastian/object-reflector",
- "version": "3.0.0",
+ "version": "2.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-reflector.git",
- "reference": "24ed13d98130f0e7122df55d06c5c4942a577957"
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957",
- "reference": "24ed13d98130f0e7122df55d06c5c4942a577957",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "3.0-dev"
+ "dev-master": "2.0-dev"
}
},
"autoload": {
@@ -1547,7 +1874,7 @@
"homepage": "https://github.com/sebastianbergmann/object-reflector/",
"support": {
"issues": "https://github.com/sebastianbergmann/object-reflector/issues",
- "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0"
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
},
"funding": [
{
@@ -1555,32 +1882,32 @@
"type": "github"
}
],
- "time": "2023-02-03T07:06:18+00:00"
+ "time": "2020-10-26T13:14:26+00:00"
},
{
"name": "sebastian/recursion-context",
- "version": "5.0.1",
+ "version": "4.0.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a"
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a",
- "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.5"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.0-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -1610,8 +1937,7 @@
"homepage": "https://github.com/sebastianbergmann/recursion-context",
"support": {
"issues": "https://github.com/sebastianbergmann/recursion-context/issues",
- "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
- "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1"
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
},
"funding": [
{
@@ -1631,32 +1957,86 @@
"type": "tidelift"
}
],
- "time": "2025-08-10T07:50:56+00:00"
+ "time": "2025-08-10T06:57:39+00:00"
+ },
+ {
+ "name": "sebastian/resource-operations",
+ "version": "3.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides a list of PHP built-in functions that operate on resources",
+ "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "support": {
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-14T16:00:52+00:00"
},
{
"name": "sebastian/type",
- "version": "4.0.0",
+ "version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/type.git",
- "reference": "462699a16464c3944eefc02ebdd77882bd3925bf"
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf",
- "reference": "462699a16464c3944eefc02ebdd77882bd3925bf",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^10.0"
+ "phpunit/phpunit": "^9.5"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "4.0-dev"
+ "dev-master": "3.2-dev"
}
},
"autoload": {
@@ -1679,7 +2059,7 @@
"homepage": "https://github.com/sebastianbergmann/type",
"support": {
"issues": "https://github.com/sebastianbergmann/type/issues",
- "source": "https://github.com/sebastianbergmann/type/tree/4.0.0"
+ "source": "https://github.com/sebastianbergmann/type/tree/3.2.1"
},
"funding": [
{
@@ -1687,29 +2067,29 @@
"type": "github"
}
],
- "time": "2023-02-03T07:10:45+00:00"
+ "time": "2023-02-03T06:13:03+00:00"
},
{
"name": "sebastian/version",
- "version": "4.0.1",
+ "version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/version.git",
- "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17"
+ "reference": "c6c1022351a901512170118436c764e473f6de8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17",
- "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c",
"shasum": ""
},
"require": {
- "php": ">=8.1"
+ "php": ">=7.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "4.0-dev"
+ "dev-master": "3.0-dev"
}
},
"autoload": {
@@ -1732,7 +2112,7 @@
"homepage": "https://github.com/sebastianbergmann/version",
"support": {
"issues": "https://github.com/sebastianbergmann/version/issues",
- "source": "https://github.com/sebastianbergmann/version/tree/4.0.1"
+ "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
},
"funding": [
{
@@ -1740,7 +2120,7 @@
"type": "github"
}
],
- "time": "2023-02-07T11:34:05+00:00"
+ "time": "2020-09-28T06:39:44+00:00"
},
{
"name": "theseer/tokenizer",
@@ -1791,6 +2171,117 @@
}
],
"time": "2025-11-17T20:03:58+00:00"
+ },
+ {
+ "name": "wp-phpunit/wp-phpunit",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/wp-phpunit/wp-phpunit.git",
+ "reference": "06828a65f8276e31368fbe4c5f3d445332abc4c5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/06828a65f8276e31368fbe4c5f3d445332abc4c5",
+ "reference": "06828a65f8276e31368fbe4c5f3d445332abc4c5",
+ "shasum": ""
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "__loaded.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Evan Mattson",
+ "email": "me@aaemnnost.tv"
+ },
+ {
+ "name": "WordPress Community",
+ "homepage": "https://wordpress.org/about/"
+ }
+ ],
+ "description": "WordPress core PHPUnit library",
+ "homepage": "https://github.com/wp-phpunit",
+ "keywords": [
+ "phpunit",
+ "test",
+ "wordpress"
+ ],
+ "support": {
+ "docs": "https://github.com/wp-phpunit/docs",
+ "issues": "https://github.com/wp-phpunit/issues",
+ "source": "https://github.com/wp-phpunit/wp-phpunit"
+ },
+ "time": "2026-05-21T02:56:35+00:00"
+ },
+ {
+ "name": "yoast/phpunit-polyfills",
+ "version": "2.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Yoast/PHPUnit-Polyfills.git",
+ "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27",
+ "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6",
+ "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "yoast/yoastcs": "^3.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "phpunitpolyfills-autoload.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Team Yoast",
+ "email": "support@yoast.com",
+ "homepage": "https://yoast.com"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors"
+ }
+ ],
+ "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests",
+ "homepage": "https://github.com/Yoast/PHPUnit-Polyfills",
+ "keywords": [
+ "phpunit",
+ "polyfill",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues",
+ "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy",
+ "source": "https://github.com/Yoast/PHPUnit-Polyfills"
+ },
+ "time": "2025-08-10T05:13:49+00:00"
}
],
"aliases": [],
@@ -1802,5 +2293,8 @@
"php": ">=8.2"
},
"platform-dev": {},
+ "platform-overrides": {
+ "php": "8.2"
+ },
"plugin-api-version": "2.9.0"
}
diff --git a/gravityforms-altcha.php b/gravityforms-altcha.php
index 1b7fd0a..81bfcb0 100644
--- a/gravityforms-altcha.php
+++ b/gravityforms-altcha.php
@@ -6,7 +6,7 @@
* Plugin Name: ALTCHA for Gravity Forms
* Plugin URI: https://github.com/generoi/gravityforms-altcha
* Description: Invisible ALTCHA spam protection for Gravity Forms — uses the MIT-licensed altcha-org/altcha PHP library and the ALTCHA widget web component to proof-of-work-verify every form submission with no user interaction.
- * Version: 0.2.0
+ * Version: 0.3.0
* Requires at least: 6.0
* Requires PHP: 8.2
* Author: Genero
diff --git a/phpunit.integration.xml.dist b/phpunit.integration.xml.dist
new file mode 100644
index 0000000..ee4fbe2
--- /dev/null
+++ b/phpunit.integration.xml.dist
@@ -0,0 +1,15 @@
+
+
+
+
+ test/integration
+
+
+
+
+
+
+
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index a410629..a6d11d8 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,10 +1,16 @@
+ bootstrap="test/bootstrap.php"
+ colors="true"
+ beStrictAboutTestsThatDoNotTestAnything="true">
+
- test/unit
+ test/unit
+
+
+
+ test/mocked
diff --git a/src/Challenge.php b/src/Challenge.php
index c4db8cd..c36fc08 100644
--- a/src/Challenge.php
+++ b/src/Challenge.php
@@ -22,11 +22,26 @@
class Challenge
{
/**
- * Iterations the client must enumerate to find the matching derived key.
- * 10k is the upstream example default — solves in ~50–500 ms on modern
- * hardware, slow enough to deter scripted abuse but invisible to humans.
+ * PBKDF2 iterations per attempt the client must grind through to solve the
+ * challenge. The work scales linearly with this number, so it is the main
+ * lever for making each submission more expensive to a bot.
+ *
+ * 200k is ~20× the upstream example default of 10k. Because the widget runs
+ * `auto=onload` in a worker the moment the form renders, the proof is
+ * almost always finished long before a human submits, so the higher cost
+ * stays invisible in practice. Tune via the `genero/gravityforms_altcha/cost`
+ * filter if you support low-end devices.
*/
- public const DEFAULT_COST = 10000;
+ public const DEFAULT_COST = 200000;
+
+ /**
+ * Bounds for an admin-configured cost. The floor keeps the proof from
+ * becoming trivial; the ceiling stops a fat-fingered value from locking
+ * real visitors out behind a multi-minute solve.
+ */
+ public const MIN_COST = 1000;
+
+ public const MAX_COST = 5000000;
/**
* Window during which a generated challenge stays usable. Long enough for
@@ -52,6 +67,15 @@ public function create(): AltchaChallenge
));
}
+ /**
+ * Constrains a cost to the supported range (see {@see self::MIN_COST} /
+ * {@see self::MAX_COST}).
+ */
+ public static function clampCost(int $cost): int
+ {
+ return max(self::MIN_COST, min(self::MAX_COST, $cost));
+ }
+
/**
* Verifies a base64-encoded payload as posted by the ALTCHA widget. Both
* client-solution payloads (the common case) and server-signature payloads
@@ -102,4 +126,54 @@ public function verify(string $base64Payload): bool
payload: new Payload($challenge, $solution),
))->verified;
}
+
+ /**
+ * Extracts a stable, unique fingerprint from a solved payload so callers
+ * can enforce one-time use (replay protection). The challenge `signature`
+ * is an HMAC over the challenge's random salt + nonce, so it uniquely
+ * identifies a single issued challenge — two different visitors (or browser
+ * tabs) always get distinct signatures, so deduping on it only ever blocks
+ * resubmitting the very same solved challenge.
+ *
+ * `expiresAt` is returned so the caller can scope the dedup record to the
+ * challenge's remaining lifetime — past that the payload can't verify
+ * anyway, so there's nothing left to replay.
+ *
+ * @return array{signature: string, expiresAt: ?int}|null Null when the
+ * payload can't be
+ * parsed or carries
+ * no signature.
+ */
+ public function fingerprint(string $base64Payload): ?array
+ {
+ if ($base64Payload === '') {
+ return null;
+ }
+
+ $decoded = base64_decode($base64Payload, true);
+ if ($decoded === false) {
+ return null;
+ }
+
+ $payload = json_decode($decoded, true);
+ if (! is_array($payload)) {
+ return null;
+ }
+
+ // Client-solution payloads carry the signature under `challenge`;
+ // server-signature payloads (Sentinel) carry it at the top level.
+ $signature = $payload['challenge']['signature']
+ ?? ($payload['signature'] ?? null);
+
+ if (! is_string($signature) || $signature === '') {
+ return null;
+ }
+
+ $expiresAt = $payload['challenge']['parameters']['expiresAt'] ?? null;
+
+ return [
+ 'signature' => $signature,
+ 'expiresAt' => is_int($expiresAt) ? $expiresAt : null,
+ ];
+ }
}
diff --git a/src/ChallengeEndpoint.php b/src/ChallengeEndpoint.php
index 9bfe7fc..01cd0a7 100644
--- a/src/ChallengeEndpoint.php
+++ b/src/ChallengeEndpoint.php
@@ -22,9 +22,22 @@ public static function registerRoutes(): void
]);
}
- public static function handle(): \WP_REST_Response
+ public static function handle(\WP_REST_Request $request): \WP_REST_Response
{
- $challenge = (new Challenge(Plugin::getInstance()->hmacKey()))->create();
+ // The widget appends the form id (see Integration::injectWidget) so the
+ // per-form cost setting applies. Absent/invalid → global/default cost.
+ $formId = $request->get_param('form_id');
+ $formId = is_numeric($formId) ? (int) $formId : null;
+
+ /**
+ * Filters the proof-of-work cost (PBKDF2 iterations per attempt). The
+ * default is resolved from the per-form / global ALTCHA settings (see
+ * {@see Settings::costForForm()}); the form id is passed for context.
+ * Higher is more expensive for bots but slower on low-end devices.
+ */
+ $cost = (int) apply_filters('genero/gravityforms_altcha/cost', Settings::costForForm($formId), $formId);
+
+ $challenge = (new Challenge(Plugin::getInstance()->hmacKey(), $cost))->create();
// The widget calls this on every form render, so caches between the
// browser and PHP must not pin one challenge to multiple visitors.
@@ -34,8 +47,10 @@ public static function handle(): \WP_REST_Response
return $response;
}
- public static function url(): string
+ public static function url(?int $formId = null): string
{
- return rest_url(self::NAMESPACE.self::ROUTE);
+ $url = rest_url(self::NAMESPACE.self::ROUTE);
+
+ return $formId !== null ? add_query_arg('form_id', $formId, $url) : $url;
}
}
diff --git a/src/EmailValidator.php b/src/EmailValidator.php
new file mode 100644
index 0000000..1dfbc9d
--- /dev/null
+++ b/src/EmailValidator.php
@@ -0,0 +1,234 @@
+ $form
+ * @param object $field
+ * @return array{is_valid: bool, message?: string}
+ */
+ public function validateField($result, $value, $form, $field)
+ {
+ if (! Settings::emailValidationEnabled()) {
+ return $result;
+ }
+
+ if (! is_object($field) || ($field->type ?? '') !== 'email') {
+ return $result;
+ }
+
+ if (! ($result['is_valid'] ?? true)) {
+ return $result; // already invalid (e.g. required/format) — leave it
+ }
+
+ $email = is_array($value) ? ($value[0] ?? '') : (string) $value;
+ if ($email === '') {
+ return $result;
+ }
+
+ /**
+ * Filters whether to validate this field/form. Return false to skip.
+ */
+ if (! apply_filters('genero/gravityforms_altcha/email_should_validate', true, $field, $form)) {
+ return $result;
+ }
+
+ if (! self::shouldBlock(self::validate($email), Settings::emailBlockModes())) {
+ return $result;
+ }
+
+ /**
+ * Filters the message shown when an email is rejected.
+ */
+ $message = apply_filters(
+ 'genero/gravityforms_altcha/email_error_message',
+ __('Please enter a valid, reachable email address.', 'gravityforms-altcha'),
+ $field,
+ $form,
+ );
+
+ return ['is_valid' => false, 'message' => $message];
+ }
+
+ /**
+ * @return array{status: string, disposable: bool, role: bool, reason: ?string}
+ */
+ public static function validate(string $email): array
+ {
+ $email = strtolower(trim($email));
+
+ if ($email === '' || ! is_email($email)) {
+ return self::result('undeliverable', reason: 'invalid_syntax');
+ }
+
+ $cacheKey = self::CACHE_PREFIX.hash('sha256', $email);
+ $cached = get_transient($cacheKey);
+ if (is_array($cached)) {
+ return $cached;
+ }
+
+ $result = self::bouncer($email);
+
+ if (self::isDefinitive($result)) {
+ set_transient($cacheKey, $result, self::CACHE_TTL);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Whether a result reflects a real provider verdict (vs. a transient infra
+ * failure we shouldn't cache for a day).
+ *
+ * @param array{reason?: ?string} $result
+ */
+ public static function isDefinitive(array $result): bool
+ {
+ $reason = (string) ($result['reason'] ?? '');
+
+ if (str_starts_with($reason, 'http_')) {
+ return false;
+ }
+
+ return ! in_array($reason, ['missing_api_key', 'malformed_response'], true);
+ }
+
+ /**
+ * Normalised verdict (no block decision — that depends on the admin-selected
+ * modes; see {@see self::shouldBlock()}).
+ *
+ * @return array{status: string, disposable: bool, role: bool, reason: ?string}
+ */
+ public static function result(string $status, bool $disposable = false, bool $role = false, ?string $reason = null): array
+ {
+ return compact('status', 'disposable', 'role', 'reason');
+ }
+
+ /**
+ * Whether a verdict should block, given which modes the admin enabled. Any
+ * uncertainty (deliverable, unknown, or a mode left off) lets it through.
+ *
+ * @param array{status?: string, disposable?: bool} $verdict
+ * @param array{undeliverable?: bool, risky?: bool, disposable?: bool} $modes
+ */
+ public static function shouldBlock(array $verdict, array $modes): bool
+ {
+ $status = $verdict['status'] ?? 'unknown';
+
+ if (! empty($modes['undeliverable']) && $status === 'undeliverable') {
+ return true;
+ }
+
+ if (! empty($modes['risky']) && $status === 'risky') {
+ return true;
+ }
+
+ if (! empty($modes['disposable']) && ! empty($verdict['disposable'])) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Bouncer real-time single email verify.
+ *
+ * @see https://docs.usebouncer.com/api-reference/real-time/verify-email
+ *
+ * @return array{status: string, disposable: bool, role: bool, reason: ?string}
+ */
+ private static function bouncer(string $email): array
+ {
+ $apiKey = self::apiKey();
+ if ($apiKey === '') {
+ return self::result('unknown', reason: 'missing_api_key');
+ }
+
+ $response = wp_remote_get(
+ 'https://api.usebouncer.com/v1.1/email/verify?'.http_build_query(['email' => $email, 'timeout' => 8]),
+ [
+ 'timeout' => self::HTTP_TIMEOUT,
+ 'headers' => ['x-api-key' => $apiKey],
+ ],
+ );
+
+ if (is_wp_error($response)) {
+ return self::result('unknown', reason: 'http_error:'.$response->get_error_code());
+ }
+
+ $code = wp_remote_retrieve_response_code($response);
+ if ($code !== 200) {
+ return self::result('unknown', reason: 'http_status:'.$code);
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+ if (! is_array($body) || ! isset($body['status'])) {
+ return self::result('unknown', reason: 'malformed_response');
+ }
+
+ $status = in_array($body['status'], ['deliverable', 'undeliverable', 'risky', 'unknown'], true)
+ ? $body['status']
+ : 'unknown';
+
+ $disposable = ($body['account']['disposable'] ?? 'no') === 'yes'
+ || ($body['domain']['disposable'] ?? 'no') === 'yes';
+
+ $role = ($body['account']['role'] ?? 'no') === 'yes';
+
+ return self::result($status, $disposable, $role, $body['reason'] ?? null);
+ }
+
+ private static function apiKey(): string
+ {
+ /**
+ * Filters the Bouncer API key. Defaults to the BOUNCER_API_KEY env var.
+ */
+ $key = (string) apply_filters('genero/gravityforms_altcha/bouncer_api_key', '');
+ if ($key !== '') {
+ return $key;
+ }
+
+ $env = getenv('BOUNCER_API_KEY');
+ if ($env === false || $env === '') {
+ $env = $_ENV['BOUNCER_API_KEY'] ?? $_SERVER['BOUNCER_API_KEY'] ?? '';
+ }
+
+ return (string) $env;
+ }
+}
diff --git a/src/Integration.php b/src/Integration.php
index 6884884..642bb33 100644
--- a/src/Integration.php
+++ b/src/Integration.php
@@ -33,7 +33,8 @@ public function injectWidget(string $buttonInput, array $form): string
return $buttonInput;
}
- $endpoint = esc_url(ChallengeEndpoint::url());
+ $formId = isset($form['id']) ? (int) $form['id'] : null;
+ $endpoint = esc_url(ChallengeEndpoint::url($formId ?: null));
$widget = sprintf(
'',
$endpoint
@@ -62,7 +63,7 @@ public function validate(array $result): array
? sanitize_text_field(wp_unslash($_POST[self::POST_FIELD]))
: '';
- if ($this->challenge()->verify($payload)) {
+ if ($this->challenge()->verify($payload) && ! $this->isReplay($payload)) {
return $result;
}
@@ -73,6 +74,45 @@ public function validate(array $result): array
return $result;
}
+ /**
+ * One-time-use enforcement. A signed challenge stays verifiable until it
+ * expires, so without this a bot could solve the proof once and replay the
+ * same payload across many submissions, paying the proof-of-work cost only
+ * once. We remember each challenge's unique signature for the rest of its
+ * lifetime and reject any payload we've already accepted.
+ *
+ * Only called after a successful verify(), so we never store fingerprints
+ * for forged/garbage payloads. Returns false (don't block) when the payload
+ * can't be fingerprinted — verify() already vouched for it.
+ *
+ * The check-then-set isn't atomic, so two truly simultaneous replays of the
+ * same payload could both slip through; that single-extra-submission race is
+ * an acceptable trade for not depending on an atomic cache backend.
+ */
+ private function isReplay(string $payload): bool
+ {
+ $fingerprint = $this->challenge()->fingerprint($payload);
+ if ($fingerprint === null) {
+ return false;
+ }
+
+ $key = 'gfaltcha_seen_'.substr(hash('sha256', $fingerprint['signature']), 0, 32);
+
+ if (get_transient($key)) {
+ return true;
+ }
+
+ // Scope the record to the challenge's remaining life; once it expires
+ // the payload can't verify anyway. Fall back to an hour if unknown.
+ $ttl = $fingerprint['expiresAt'] !== null
+ ? max(MINUTE_IN_SECONDS, $fingerprint['expiresAt'] - time())
+ : HOUR_IN_SECONDS;
+
+ set_transient($key, 1, $ttl);
+
+ return false;
+ }
+
/**
* @param array $form
*/
diff --git a/src/Plugin.php b/src/Plugin.php
index 488746e..0ce14b1 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -4,7 +4,7 @@
class Plugin
{
- public const VERSION = '0.2.0';
+ public const VERSION = '0.3.0';
public const SLUG = 'gravityforms-altcha';
@@ -65,6 +65,8 @@ public function registerHooks(): void
Integration::register();
ChallengeEndpoint::register();
+ SpamFilter::register();
+ EmailValidator::register();
}
/**
diff --git a/src/Settings.php b/src/Settings.php
index 3a841ab..971d6c1 100644
--- a/src/Settings.php
+++ b/src/Settings.php
@@ -68,6 +68,61 @@ public function plugin_settings_fields()
'tooltip' => esc_html__('When on, ALTCHA protects every Gravity Form on the site. When off, enable it per form from the form\'s ALTCHA settings tab.', 'gravityforms-altcha'),
'default_value' => false,
],
+ [
+ 'name' => 'cost',
+ 'type' => 'select',
+ 'label' => esc_html__('Protection strength', 'gravityforms-altcha'),
+ 'tooltip' => esc_html__('How hard the background proof-of-work is. Stronger settings cost bots more per submission but take longer to solve on low-end devices — the work runs while the form is being filled, so it is normally invisible. Can be overridden per form.', 'gravityforms-altcha'),
+ 'default_value' => (string) Challenge::DEFAULT_COST,
+ 'choices' => self::costChoices(false),
+ ],
+ [
+ 'name' => 'enable_rate_limit',
+ 'type' => 'toggle',
+ 'label' => esc_html__('Rate limiting', 'gravityforms-altcha'),
+ 'tooltip' => esc_html__('Flags submissions as spam (recoverable — never blocked) once an IP submits the same form more than a few times an hour. Applies to all Gravity Forms.', 'gravityforms-altcha'),
+ 'default_value' => false,
+ ],
+ [
+ 'name' => 'enable_content_filter',
+ 'type' => 'toggle',
+ 'label' => esc_html__('Content spam filtering', 'gravityforms-altcha'),
+ 'tooltip' => esc_html__('Flags submissions as spam (recoverable — never blocked) when the message contains definite-spam keywords or several spam signals. Applies to all Gravity Forms.', 'gravityforms-altcha'),
+ 'default_value' => false,
+ ],
+ [
+ 'name' => 'enable_email_validation',
+ 'type' => 'toggle',
+ 'label' => esc_html__('Email validation', 'gravityforms-altcha'),
+ 'tooltip' => esc_html__('Asks the visitor to correct a bad email address before submitting. Verifies via Bouncer — requires the BOUNCER_API_KEY environment variable, and sends the email to a third-party service. Fails open (never blocks) if the service is unavailable. Applies to all Gravity Forms.', 'gravityforms-altcha'),
+ 'default_value' => false,
+ ],
+ [
+ 'name' => 'email_block',
+ 'type' => 'checkbox',
+ 'label' => esc_html__('Reject addresses that are', 'gravityforms-altcha'),
+ 'dependency' => [
+ 'live' => true,
+ 'fields' => [['field' => 'enable_email_validation']],
+ ],
+ 'choices' => [
+ [
+ 'name' => 'email_block_undeliverable',
+ 'label' => esc_html__('Undeliverable — the mailbox or domain does not exist', 'gravityforms-altcha'),
+ 'default_value' => true,
+ ],
+ [
+ 'name' => 'email_block_risky',
+ 'label' => esc_html__('Risky — catch-all, role, or low-quality (may reject some real addresses)', 'gravityforms-altcha'),
+ 'default_value' => false,
+ ],
+ [
+ 'name' => 'email_block_disposable',
+ 'label' => esc_html__('Disposable — a temporary, throwaway inbox', 'gravityforms-altcha'),
+ 'default_value' => true,
+ ],
+ ],
+ ],
],
],
];
@@ -92,11 +147,44 @@ public function form_settings_fields($form)
'tooltip' => esc_html__('Adds invisible ALTCHA spam protection to this form. Has no extra effect when "Enable for all forms" is turned on globally.', 'gravityforms-altcha'),
'default_value' => false,
],
+ [
+ 'name' => 'cost',
+ 'type' => 'select',
+ 'label' => esc_html__('Protection strength', 'gravityforms-altcha'),
+ 'tooltip' => esc_html__('Override the protection strength for this form only, or inherit the site-wide ALTCHA setting.', 'gravityforms-altcha'),
+ 'default_value' => '',
+ 'choices' => self::costChoices(true),
+ ],
],
],
];
}
+ /**
+ * Preset protection strengths shown in the settings dropdowns. Values are
+ * proof-of-work costs (PBKDF2 iterations); labels describe the trade-off in
+ * plain terms with a rough solve time so admins don't have to reason about
+ * raw numbers. Power users can still set any exact value via the
+ * `genero/gravityforms_altcha/cost` filter.
+ *
+ * @return array
+ */
+ public static function costChoices(bool $includeInherit): array
+ {
+ $choices = [];
+
+ if ($includeInherit) {
+ $choices[] = ['label' => esc_html__('Inherit site-wide setting', 'gravityforms-altcha'), 'value' => ''];
+ }
+
+ return array_merge($choices, [
+ ['label' => esc_html__('Low — lightest, weakest deterrent (~1s)', 'gravityforms-altcha'), 'value' => '50000'],
+ ['label' => esc_html__('Standard — recommended balance (~4s)', 'gravityforms-altcha'), 'value' => (string) Challenge::DEFAULT_COST],
+ ['label' => esc_html__('High — stronger deterrent (~10s)', 'gravityforms-altcha'), 'value' => '500000'],
+ ['label' => esc_html__('Very high — strongest, may briefly delay submit on old devices (~20s)', 'gravityforms-altcha'), 'value' => '1000000'],
+ ]);
+ }
+
/**
* Whether ALTCHA should protect a given form, based on the saved settings:
* the global "enable for all forms" toggle wins, otherwise the form's own
@@ -116,4 +204,69 @@ public static function isEnabledForForm(array $form): bool
return is_array($formSettings) && ! empty($formSettings['enabled']);
}
+
+ public static function rateLimitEnabled(): bool
+ {
+ return (bool) self::get_instance()->get_plugin_setting('enable_rate_limit');
+ }
+
+ public static function contentFilterEnabled(): bool
+ {
+ return (bool) self::get_instance()->get_plugin_setting('enable_content_filter');
+ }
+
+ public static function emailValidationEnabled(): bool
+ {
+ return (bool) self::get_instance()->get_plugin_setting('enable_email_validation');
+ }
+
+ /**
+ * Which Bouncer verdicts the admin has opted to reject. Unchecked / unset →
+ * false, so a verdict is only ever acted on when explicitly enabled.
+ *
+ * @return array{undeliverable: bool, risky: bool, disposable: bool}
+ */
+ public static function emailBlockModes(): array
+ {
+ $addon = self::get_instance();
+
+ return [
+ 'undeliverable' => (bool) $addon->get_plugin_setting('email_block_undeliverable'),
+ 'risky' => (bool) $addon->get_plugin_setting('email_block_risky'),
+ 'disposable' => (bool) $addon->get_plugin_setting('email_block_disposable'),
+ ];
+ }
+
+ /**
+ * Resolves the proof-of-work cost for a form: the per-form override wins,
+ * then the global setting, then the built-in default. The result is clamped
+ * to a sane range so a typo can't lock visitors out (or make the proof
+ * trivial). Pass null when no form context is available (the global/default
+ * applies).
+ */
+ public static function costForForm(?int $formId): int
+ {
+ $addon = self::get_instance();
+
+ $perForm = null;
+ if ($formId !== null && class_exists('\GFAPI')) {
+ $form = \GFAPI::get_form($formId);
+ if (is_array($form)) {
+ $formSettings = $addon->get_form_settings($form);
+ $perForm = is_array($formSettings) ? ($formSettings['cost'] ?? null) : null;
+ }
+ }
+
+ $global = $addon->get_plugin_setting('cost');
+
+ $cost = Challenge::DEFAULT_COST;
+ foreach ([$perForm, $global] as $candidate) {
+ if (is_numeric($candidate) && (int) $candidate > 0) {
+ $cost = (int) $candidate;
+ break;
+ }
+ }
+
+ return Challenge::clampCost($cost);
+ }
}
diff --git a/src/SpamFilter.php b/src/SpamFilter.php
new file mode 100644
index 0000000..8eac3a6
--- /dev/null
+++ b/src/SpamFilter.php
@@ -0,0 +1,311 @@
+ $form
+ * @param array $entry
+ */
+ public function flag($isSpam, $form, $entry): bool
+ {
+ if ($isSpam) {
+ return true;
+ }
+
+ if (! is_array($form)) {
+ return (bool) $isSpam;
+ }
+
+ if (Settings::rateLimitEnabled() && $this->exceedsRateLimit($form)) {
+ return true;
+ }
+
+ if (Settings::contentFilterEnabled()) {
+ $text = $this->extractText($form, $entry);
+ if (self::contentIsSpam($text['body'], $text['identity'], self::keywords(), self::scoreThreshold())) {
+ return true;
+ }
+ }
+
+ return (bool) $isSpam;
+ }
+
+ /**
+ * Per-IP, per-form sliding-ish counter. Allows up to the limit within the
+ * window and flags anything beyond it. An unresolvable IP is never
+ * penalised.
+ *
+ * The IP is resolved independently of Gravity Forms (sites commonly blank
+ * GF's stored IP for GDPR), and is only ever kept as a salted HMAC in a
+ * short-lived transient — the raw IP is never stored or logged.
+ *
+ * @param array $form
+ */
+ private function exceedsRateLimit(array $form): bool
+ {
+ $ip = $this->clientIp();
+ if ($ip === null) {
+ return false;
+ }
+
+ /**
+ * Filters the per-IP, per-form submission allowance and the window it
+ * applies over. Defaults to 3 submissions per hour — generous enough
+ * for a legitimate retry, tight against floods. For "1 per minute"
+ * instead, set max 1 and window MINUTE_IN_SECONDS.
+ */
+ $max = max(1, (int) apply_filters('genero/gravityforms_altcha/rate_limit_max', 3, $form));
+ $window = max(1, (int) apply_filters('genero/gravityforms_altcha/rate_limit_window', HOUR_IN_SECONDS, $form));
+
+ $key = 'gfaltcha_rl_'.($form['id'] ?? 0).'_'.self::hashIp($ip);
+ $count = (int) get_transient($key);
+ set_transient($key, $count + 1, $window);
+
+ return $count >= $max;
+ }
+
+ private function clientIp(): ?string
+ {
+ /**
+ * Ordered list of $_SERVER keys to read the client IP from. Defaults to
+ * REMOTE_ADDR only — the actual TCP peer, which can't be spoofed. If the
+ * site sits behind a CDN/proxy, PREPEND the single header that CDN sets,
+ * e.g. 'HTTP_CF_CONNECTING_IP' (Cloudflare), 'HTTP_FASTLY_CLIENT_IP'
+ * (Fastly), 'HTTP_TRUE_CLIENT_IP' (Akamai) or 'HTTP_X_REAL_IP' (nginx).
+ * Never trust a forwarded header your CDN doesn't set: it can be spoofed
+ * to evade the limit, or to push a real visitor's IP over it.
+ *
+ * @var array $headers
+ */
+ $headers = (array) apply_filters('genero/gravityforms_altcha/client_ip_headers', ['REMOTE_ADDR']);
+
+ return self::resolveIp($_SERVER, $headers);
+ }
+
+ /**
+ * Pure IP resolver (unit-testable): first header that yields a valid IP
+ * wins. Handles "client, proxy1, ..." lists by taking the first entry.
+ *
+ * @param array $server
+ * @param array $headers
+ */
+ public static function resolveIp(array $server, array $headers): ?string
+ {
+ foreach ($headers as $header) {
+ $value = $server[$header] ?? '';
+ if (! is_string($value) || $value === '' || strlen($value) > 200) {
+ continue;
+ }
+
+ $ip = trim(explode(',', $value)[0]);
+ if (filter_var($ip, FILTER_VALIDATE_IP)) {
+ return $ip;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * GDPR-friendly, non-reversible key for an IP: a keyed HMAC salted with a
+ * server secret, so the small IPv4 space can't be brute-forced back to the
+ * raw address. Only ever lives in a 60s transient; the IP itself is never
+ * stored.
+ */
+ public static function hashIp(string $ip): string
+ {
+ return hash_hmac('sha256', $ip, wp_salt('auth'));
+ }
+
+ /**
+ * Gathers the visitor-entered free text for scoring. Returns the full body
+ * plus, separately, the identity (name) text — a URL there is a far stronger
+ * signal than one in a message, so the scorer weighs it more.
+ *
+ * Walks composite fields (name/address store values in sub-inputs), which a
+ * naive `rgar($entry, $field->id)` would miss entirely.
+ *
+ * @param array $form
+ * @param array $entry
+ * @return array{body: string, identity: string}
+ */
+ private function extractText(array $form, array $entry): array
+ {
+ $bodyTypes = ['text', 'textarea', 'name', 'address', 'website', 'post_title', 'post_content', 'post_excerpt'];
+
+ $body = '';
+ $identity = '';
+
+ foreach ($form['fields'] ?? [] as $field) {
+ $type = $field->type ?? '';
+ $value = $this->fieldValue($field, $entry);
+ if ($value === '') {
+ continue;
+ }
+
+ if (in_array($type, $bodyTypes, true)) {
+ $body .= ' '.$value;
+ }
+
+ if ($type === 'name') {
+ $identity .= ' '.$value;
+ }
+ }
+
+ return ['body' => trim($body), 'identity' => trim($identity)];
+ }
+
+ /**
+ * Reads a field's submitted value, joining sub-inputs for composite fields
+ * (name, address) so their text is actually seen.
+ *
+ * @param object $field
+ * @param array $entry
+ */
+ private function fieldValue($field, array $entry): string
+ {
+ if (! empty($field->inputs) && is_array($field->inputs)) {
+ $parts = [];
+ foreach ($field->inputs as $input) {
+ $parts[] = (string) rgar($entry, (string) ($input['id'] ?? ''));
+ }
+
+ return trim(implode(' ', array_filter($parts)));
+ }
+
+ return (string) rgar($entry, (string) ($field->id ?? ''));
+ }
+
+ /**
+ * Pure spam test (no WordPress/GF dependencies, so it is unit-testable).
+ * A definite keyword flags on a single hit; otherwise weaker signals must
+ * accumulate to the threshold — so a lone link or foreign word never trips
+ * it, keeping false positives near zero.
+ *
+ * @param string $body All visitor free text.
+ * @param string $identity Just the name field(s); a URL here is damning.
+ * @param array $keywords
+ */
+ public static function contentIsSpam(string $body, string $identity, array $keywords, int $threshold): bool
+ {
+ $identity = self::normalize($identity);
+ $body = self::normalize($body);
+ $combined = trim($identity.' '.$body);
+
+ if ($combined === '') {
+ return false;
+ }
+
+ // Definite-spam keywords — matched on word boundaries (unicode-aware) so
+ // a keyword can't trip on a substring of an innocent word.
+ foreach ($keywords as $keyword) {
+ if ($keyword !== '' && self::containsWord($combined, $keyword)) {
+ return true;
+ }
+ }
+
+ $score = 0;
+
+ // Links (http(s):// and scheme-less www.), graduated: a single link is
+ // innocent, a pile of them is not.
+ $links = self::countLinks($combined);
+ if ($links >= 5) {
+ $score += 3;
+ } elseif ($links >= 3) {
+ $score += 2;
+ }
+
+ // A URL in the name field is near-certain spam — real names aren't links.
+ if ($identity !== '' && self::countLinks($identity) >= 1) {
+ $score += 3;
+ }
+
+ if (preg_match('~\[url=|?a\s~i', $combined)) {
+ $score += 2; // injected markup
+ }
+
+ if (preg_match('~[\p{Cyrillic}\p{Han}\p{Hangul}]~u', $combined)) {
+ $score += 2; // wrong script for a FI/SV site
+ }
+
+ return $score >= $threshold;
+ }
+
+ /**
+ * Strips zero-width / invisible characters used to break keyword and link
+ * matching, and collapses whitespace.
+ */
+ private static function normalize(string $text): string
+ {
+ $text = preg_replace('~[\x{200B}-\x{200D}\x{2060}\x{FEFF}\x{00AD}]~u', '', $text) ?? $text;
+
+ return trim(preg_replace('~\s+~u', ' ', $text) ?? $text);
+ }
+
+ /**
+ * Case-insensitive, unicode-aware whole-word match (avoids the Scunthorpe
+ * problem when a short keyword is configured).
+ */
+ private static function containsWord(string $text, string $word): bool
+ {
+ return (bool) preg_match('~(?
+ */
+ private static function keywords(): array
+ {
+ /**
+ * Filters the list of definite-spam keywords. A single case-insensitive
+ * substring match marks the submission as spam, so keep this list to
+ * terms that never appear in a legitimate message.
+ */
+ return (array) apply_filters('genero/gravityforms_altcha/spam_keywords', ['viagra', 'casino']);
+ }
+
+ private static function scoreThreshold(): int
+ {
+ /**
+ * Filters the score required for the weaker, additive heuristics to mark
+ * a submission as spam. Each signal contributes 2, so the default of 3
+ * requires at least two independent signals.
+ */
+ return (int) apply_filters('genero/gravityforms_altcha/spam_score_threshold', 3);
+ }
+}
diff --git a/test/bootstrap-integration.php b/test/bootstrap-integration.php
new file mode 100644
index 0000000..a910784
--- /dev/null
+++ b/test/bootstrap-integration.php
@@ -0,0 +1,35 @@
+markTestSkipped('Gravity Forms is not active.');
+ }
+ }
+}
diff --git a/test/integration/SettingsIntegrationTest.php b/test/integration/SettingsIntegrationTest.php
new file mode 100644
index 0000000..e025b1d
--- /dev/null
+++ b/test/integration/SettingsIntegrationTest.php
@@ -0,0 +1,79 @@
+requireGravityForms();
+ // Reset global settings to a known baseline.
+ Settings::get_instance()->update_plugin_settings([]);
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->formIds as $id) {
+ \GFAPI::delete_form($id);
+ }
+ $this->formIds = [];
+ parent::tearDown();
+ }
+
+ private function setGlobal(array $settings): void
+ {
+ $addon = Settings::get_instance();
+ $addon->update_plugin_settings(array_merge($addon->get_plugin_settings() ?: [], $settings));
+ }
+
+ public function test_cost_defaults_to_plugin_default(): void
+ {
+ $this->assertSame(Challenge::DEFAULT_COST, Settings::costForForm(null));
+ }
+
+ public function test_global_cost_setting_is_applied(): void
+ {
+ $this->setGlobal(['cost' => '500000']);
+ $this->assertSame(500000, Settings::costForForm(null));
+ }
+
+ public function test_per_form_cost_overrides_global(): void
+ {
+ $this->setGlobal(['cost' => '500000']);
+
+ $formId = \GFAPI::add_form([
+ 'title' => 'Per-form cost',
+ 'fields' => [],
+ 'gravityforms-altcha' => ['cost' => '50000'],
+ ]);
+ $this->formIds[] = (int) $formId;
+
+ $this->assertSame(50000, Settings::costForForm((int) $formId));
+ }
+
+ public function test_out_of_range_cost_is_clamped(): void
+ {
+ $this->setGlobal(['cost' => '999999999']);
+ $this->assertSame(Challenge::MAX_COST, Settings::costForForm(null));
+ }
+
+ public function test_email_block_modes_default_to_unset(): void
+ {
+ $this->assertSame(
+ ['undeliverable' => false, 'risky' => false, 'disposable' => false],
+ Settings::emailBlockModes(),
+ );
+ }
+}
diff --git a/test/integration/SpamFilterIntegrationTest.php b/test/integration/SpamFilterIntegrationTest.php
new file mode 100644
index 0000000..354b793
--- /dev/null
+++ b/test/integration/SpamFilterIntegrationTest.php
@@ -0,0 +1,99 @@
+requireGravityForms();
+
+ $addon = Settings::get_instance();
+ $addon->update_plugin_settings(array_merge($addon->get_plugin_settings() ?: [], [
+ 'enable_content_filter' => '1',
+ 'enable_rate_limit' => '',
+ ]));
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->formIds as $id) {
+ \GFAPI::delete_form($id);
+ }
+ $this->formIds = [];
+ parent::tearDown();
+ }
+
+ private function makeForm(): array
+ {
+ $id = \GFAPI::add_form([
+ 'title' => 'Spam test form',
+ 'fields' => [
+ ['id' => 1, 'type' => 'name', 'inputs' => [['id' => '1.3'], ['id' => '1.6']]],
+ ['id' => 2, 'type' => 'textarea'],
+ ],
+ ]);
+ $this->formIds[] = (int) $id;
+
+ return \GFAPI::get_form($id);
+ }
+
+ private function isSpam(array $form, array $entry): bool
+ {
+ return (bool) apply_filters('gform_entry_is_spam', false, $form, $entry);
+ }
+
+ public function test_keyword_in_message_is_flagged(): void
+ {
+ $this->assertTrue($this->isSpam($this->makeForm(), ['2' => 'cheap VIAGRA, best price']));
+ }
+
+ public function test_url_in_name_field_is_flagged(): void
+ {
+ $this->assertTrue($this->isSpam($this->makeForm(), [
+ '1.3' => 'http://spam.example', '1.6' => 'x', '2' => 'hello',
+ ]));
+ }
+
+ public function test_clean_submission_passes(): void
+ {
+ $this->assertFalse($this->isSpam($this->makeForm(), [
+ '1.3' => 'Matti', '1.6' => 'Meikäläinen', '2' => 'Kiitos hyvästä tuotteesta!',
+ ]));
+ }
+
+ public function test_rate_limit_flags_beyond_the_allowance(): void
+ {
+ Settings::get_instance()->update_plugin_settings(array_merge(
+ Settings::get_instance()->get_plugin_settings() ?: [],
+ ['enable_rate_limit' => '1', 'enable_content_filter' => ''],
+ ));
+ add_filter('genero/gravityforms_altcha/client_ip_headers', fn () => ['REMOTE_ADDR']);
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.200';
+
+ $form = $this->makeForm();
+ $entry = ['1.3' => 'Matti', '1.6' => 'M', '2' => 'hi'];
+
+ $results = [];
+ for ($i = 0; $i < 5; $i++) {
+ $results[] = $this->isSpam($form, $entry);
+ }
+
+ // Default 3/hour → first three pass, the rest are flagged.
+ $this->assertSame([false, false, false, true, true], $results);
+
+ unset($_SERVER['REMOTE_ADDR']);
+ }
+}
diff --git a/test/mocked/EmailValidatorBouncerTest.php b/test/mocked/EmailValidatorBouncerTest.php
new file mode 100644
index 0000000..854993f
--- /dev/null
+++ b/test/mocked/EmailValidatorBouncerTest.php
@@ -0,0 +1,142 @@
+returnArg(2);
+ Functions\when('is_email')->alias(fn ($email) => (bool) filter_var($email, FILTER_VALIDATE_EMAIL));
+ Functions\when('get_transient')->justReturn(false);
+ Functions\when('is_wp_error')->justReturn(false);
+ Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
+ }
+
+ protected function tearDown(): void
+ {
+ unset($_SERVER['BOUNCER_API_KEY']);
+ parent::tearDown();
+ }
+
+ private function stubBouncer(array $body, int $code = 200): void
+ {
+ Functions\when('wp_remote_get')->justReturn(['stub' => true]);
+ Functions\when('wp_remote_retrieve_response_code')->justReturn($code);
+ Functions\when('wp_remote_retrieve_body')->justReturn(json_encode($body));
+ }
+
+ public function test_maps_a_deliverable_verdict_and_caches_it(): void
+ {
+ $this->stubBouncer(['status' => 'deliverable', 'account' => ['role' => 'no'], 'reason' => 'accepted_email']);
+ Functions\expect('set_transient')->once(); // definitive → cached
+
+ $verdict = EmailValidator::validate('real.person@gmail.com');
+
+ $this->assertSame('deliverable', $verdict['status']);
+ $this->assertFalse($verdict['disposable']);
+ $this->assertFalse($verdict['role']);
+ }
+
+ public function test_maps_disposable_and_role_flags(): void
+ {
+ $this->stubBouncer([
+ 'status' => 'deliverable',
+ 'account' => ['disposable' => 'no', 'role' => 'yes'],
+ 'domain' => ['disposable' => 'yes'],
+ ]);
+ Functions\expect('set_transient')->once();
+
+ $verdict = EmailValidator::validate('info@mailinator.com');
+
+ $this->assertTrue($verdict['disposable']);
+ $this->assertTrue($verdict['role']);
+ }
+
+ public function test_maps_an_undeliverable_verdict(): void
+ {
+ $this->stubBouncer(['status' => 'undeliverable', 'reason' => 'rejected_email']);
+ Functions\expect('set_transient')->once();
+
+ $this->assertSame('undeliverable', EmailValidator::validate('nope@example.com')['status']);
+ }
+
+ public function test_http_error_fails_open_and_is_not_cached(): void
+ {
+ $wpError = new class
+ {
+ public function get_error_code(): string
+ {
+ return 'http_request_failed';
+ }
+ };
+ Functions\when('wp_remote_get')->justReturn($wpError);
+ Functions\when('is_wp_error')->justReturn(true);
+ Functions\when('wp_remote_retrieve_body')->justReturn('');
+ Functions\expect('set_transient')->never(); // transient failure → don't cache
+
+ $verdict = EmailValidator::validate('real@example.com');
+
+ $this->assertSame('unknown', $verdict['status']);
+ $this->assertStringStartsWith('http_error', (string) $verdict['reason']);
+ }
+
+ public function test_non_200_fails_open(): void
+ {
+ $this->stubBouncer([], 500);
+ Functions\expect('set_transient')->never();
+
+ $this->assertSame('unknown', EmailValidator::validate('real@example.com')['status']);
+ }
+
+ public function test_malformed_response_fails_open(): void
+ {
+ Functions\when('wp_remote_get')->justReturn(['stub' => true]);
+ Functions\when('wp_remote_retrieve_body')->justReturn('not-json');
+ Functions\expect('set_transient')->never();
+
+ $verdict = EmailValidator::validate('real@example.com');
+
+ $this->assertSame('unknown', $verdict['status']);
+ $this->assertSame('malformed_response', $verdict['reason']);
+ }
+
+ public function test_missing_api_key_never_calls_the_api(): void
+ {
+ unset($_SERVER['BOUNCER_API_KEY']);
+ // If wp_remote_get were called it would error (not stubbed to expect args);
+ // assert it's never hit.
+ Functions\expect('wp_remote_get')->never();
+ Functions\expect('set_transient')->never();
+
+ $verdict = EmailValidator::validate('real@example.com');
+
+ $this->assertSame('unknown', $verdict['status']);
+ $this->assertSame('missing_api_key', $verdict['reason']);
+ }
+
+ public function test_invalid_syntax_is_undeliverable_without_calling_the_api(): void
+ {
+ Functions\expect('wp_remote_get')->never();
+
+ $verdict = EmailValidator::validate('not-an-email');
+
+ $this->assertSame('undeliverable', $verdict['status']);
+ $this->assertSame('invalid_syntax', $verdict['reason']);
+ }
+}
diff --git a/test/mocked/MockedTestCase.php b/test/mocked/MockedTestCase.php
new file mode 100644
index 0000000..1a5aa33
--- /dev/null
+++ b/test/mocked/MockedTestCase.php
@@ -0,0 +1,27 @@
+ in-memory transient store */
+ private array $store = [];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->store = [];
+
+ Functions\when('apply_filters')->returnArg(2); // defaults: max 3, window HOUR, ['REMOTE_ADDR']
+ Functions\when('wp_salt')->justReturn('test-salt');
+ Functions\when('get_transient')->alias(fn ($k) => $this->store[$k] ?? false);
+ Functions\when('set_transient')->alias(function ($k, $v) {
+ $this->store[$k] = $v;
+
+ return true;
+ });
+ }
+
+ private function exceeds(array $form): bool
+ {
+ $method = new ReflectionMethod(SpamFilter::class, 'exceedsRateLimit');
+ $method->setAccessible(true);
+
+ return $method->invoke(new SpamFilter, $form);
+ }
+
+ public function test_allows_three_per_hour_then_flags(): void
+ {
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.10';
+ $form = ['id' => 42];
+
+ $results = [];
+ for ($i = 0; $i < 5; $i++) {
+ $results[] = $this->exceeds($form);
+ }
+
+ $this->assertSame([false, false, false, true, true], $results);
+
+ unset($_SERVER['REMOTE_ADDR']);
+ }
+
+ public function test_separate_ips_have_separate_counters(): void
+ {
+ $form = ['id' => 42];
+
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.10';
+ $this->exceeds($form);
+ $this->exceeds($form);
+ $this->exceeds($form);
+
+ // A different IP is unaffected by the first IP's count.
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.99';
+ $this->assertFalse($this->exceeds($form));
+
+ unset($_SERVER['REMOTE_ADDR']);
+ }
+
+ public function test_unknown_ip_is_never_penalised(): void
+ {
+ unset($_SERVER['REMOTE_ADDR']);
+ $form = ['id' => 42];
+
+ for ($i = 0; $i < 10; $i++) {
+ $this->assertFalse($this->exceeds($form));
+ }
+ }
+}
diff --git a/test/mocked/ReplayTest.php b/test/mocked/ReplayTest.php
new file mode 100644
index 0000000..c3b35b9
--- /dev/null
+++ b/test/mocked/ReplayTest.php
@@ -0,0 +1,79 @@
+ */
+ private array $store = [];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->store = [];
+
+ // hmacKey(): the filter returns null (returnArg), so get_option supplies it.
+ Functions\when('apply_filters')->returnArg(2);
+ Functions\when('plugin_dir_path')->returnArg(1);
+ Functions\when('plugin_dir_url')->returnArg(1);
+ Functions\when('get_option')->justReturn('0123456789abcdef0123456789abcdef');
+ Functions\when('get_transient')->alias(fn ($k) => $this->store[$k] ?? false);
+ Functions\when('set_transient')->alias(function ($k, $v) {
+ $this->store[$k] = $v;
+
+ return true;
+ });
+
+ // Integration::isReplay() builds a Challenge via Plugin::getInstance().
+ Plugin::getInstance(dirname(__DIR__, 2).'/gravityforms-altcha.php');
+ }
+
+ private function isReplay(string $payload): bool
+ {
+ $method = new ReflectionMethod(Integration::class, 'isReplay');
+ $method->setAccessible(true);
+
+ return $method->invoke(new Integration, $payload);
+ }
+
+ private function payloadWithSignature(string $signature): string
+ {
+ return base64_encode(json_encode([
+ 'challenge' => [
+ 'signature' => $signature,
+ 'parameters' => ['expiresAt' => time() + 600],
+ ],
+ 'solution' => ['counter' => 1, 'derivedKey' => 'ab'],
+ ]));
+ }
+
+ public function test_first_use_passes_then_replay_is_blocked(): void
+ {
+ $payload = $this->payloadWithSignature('aaaa1111');
+
+ $this->assertFalse($this->isReplay($payload), 'first submission is not a replay');
+ $this->assertTrue($this->isReplay($payload), 'second submission of the same payload is a replay');
+ }
+
+ public function test_distinct_challenges_do_not_collide(): void
+ {
+ $this->assertFalse($this->isReplay($this->payloadWithSignature('sig-a')));
+ $this->assertFalse($this->isReplay($this->payloadWithSignature('sig-b')));
+ }
+
+ public function test_unparseable_payload_is_not_treated_as_replay(): void
+ {
+ $this->assertFalse($this->isReplay('not-base64-or-json'));
+ }
+}
diff --git a/test/unit/ChallengeTest.php b/test/unit/ChallengeTest.php
index 244564e..2ddc5b6 100644
--- a/test/unit/ChallengeTest.php
+++ b/test/unit/ChallengeTest.php
@@ -83,4 +83,78 @@ public function test_verify_rejects_payload_with_garbled_solution(): void
$this->assertFalse((new Challenge(self::SECRET))->verify($payload));
}
+
+ /**
+ * @return array{0: \AltchaOrg\Altcha\Challenge, 1: string} challenge + base64 payload
+ */
+ private function solvedPayload(int $expiresSeconds = 600): array
+ {
+ $altcha = new Altcha(self::SECRET);
+ $challenge = (new Challenge(self::SECRET, cost: 1000, expiresSeconds: $expiresSeconds))->create();
+
+ $solution = $altcha->solveChallenge(new SolveChallengeOptions(
+ algorithm: new Pbkdf2,
+ challenge: $challenge,
+ ));
+ $this->assertNotNull($solution);
+
+ return [$challenge, (new Payload($challenge, $solution))->toBase64()];
+ }
+
+ public function test_fingerprint_returns_signature_and_expiry(): void
+ {
+ [$challenge, $payload] = $this->solvedPayload();
+
+ $fingerprint = (new Challenge(self::SECRET))->fingerprint($payload);
+
+ $this->assertIsArray($fingerprint);
+ $this->assertSame($challenge->signature, $fingerprint['signature']);
+ $this->assertSame($challenge->parameters->expiresAt, $fingerprint['expiresAt']);
+ }
+
+ public function test_fingerprint_is_stable_for_the_same_payload(): void
+ {
+ [, $payload] = $this->solvedPayload();
+ $challenge = new Challenge(self::SECRET);
+
+ $this->assertSame(
+ $challenge->fingerprint($payload),
+ $challenge->fingerprint($payload),
+ );
+ }
+
+ public function test_fingerprint_differs_across_challenges(): void
+ {
+ // Two independently issued challenges — as two concurrent visitors would
+ // get — must never share a fingerprint, so replay dedup can't collide
+ // between distinct users.
+ [, $a] = $this->solvedPayload();
+ [, $b] = $this->solvedPayload();
+ $challenge = new Challenge(self::SECRET);
+
+ $this->assertNotSame(
+ $challenge->fingerprint($a)['signature'],
+ $challenge->fingerprint($b)['signature'],
+ );
+ }
+
+ public function test_fingerprint_returns_null_for_unparseable_payloads(): void
+ {
+ $challenge = new Challenge(self::SECRET);
+
+ $this->assertNull($challenge->fingerprint(''));
+ $this->assertNull($challenge->fingerprint('!!!not-base64!!!'));
+ $this->assertNull($challenge->fingerprint(base64_encode('not json')));
+ $this->assertNull($challenge->fingerprint(base64_encode(json_encode(['no' => 'signature']))));
+ }
+
+ public function test_clamp_cost_constrains_to_bounds(): void
+ {
+ $this->assertSame(Challenge::MIN_COST, Challenge::clampCost(1));
+ $this->assertSame(Challenge::MIN_COST, Challenge::clampCost(Challenge::MIN_COST - 1));
+ $this->assertSame(Challenge::MAX_COST, Challenge::clampCost(Challenge::MAX_COST + 1));
+ $this->assertSame(250000, Challenge::clampCost(250000));
+ $this->assertGreaterThanOrEqual(Challenge::MIN_COST, Challenge::DEFAULT_COST);
+ $this->assertLessThanOrEqual(Challenge::MAX_COST, Challenge::DEFAULT_COST);
+ }
}
diff --git a/test/unit/EmailValidatorTest.php b/test/unit/EmailValidatorTest.php
new file mode 100644
index 0000000..d3c0af4
--- /dev/null
+++ b/test/unit/EmailValidatorTest.php
@@ -0,0 +1,67 @@
+ true, 'risky' => true, 'disposable' => true];
+
+ private const NONE = ['undeliverable' => false, 'risky' => false, 'disposable' => false];
+
+ public function test_undeliverable_blocks_only_when_that_mode_is_on(): void
+ {
+ $verdict = EmailValidator::result('undeliverable');
+ $this->assertTrue(EmailValidator::shouldBlock($verdict, ['undeliverable' => true]));
+ $this->assertFalse(EmailValidator::shouldBlock($verdict, self::NONE));
+ }
+
+ public function test_risky_blocks_only_when_that_mode_is_on(): void
+ {
+ $verdict = EmailValidator::result('risky');
+ $this->assertTrue(EmailValidator::shouldBlock($verdict, ['risky' => true]));
+ // Risky is off by default → real-ish addresses get through.
+ $this->assertFalse(EmailValidator::shouldBlock($verdict, ['undeliverable' => true, 'disposable' => true]));
+ }
+
+ public function test_disposable_blocks_independent_of_status(): void
+ {
+ $verdict = EmailValidator::result('deliverable', disposable: true);
+ $this->assertTrue(EmailValidator::shouldBlock($verdict, ['disposable' => true]));
+ $this->assertFalse(EmailValidator::shouldBlock($verdict, ['undeliverable' => true, 'risky' => true]));
+ }
+
+ public function test_deliverable_never_blocks(): void
+ {
+ $this->assertFalse(EmailValidator::shouldBlock(EmailValidator::result('deliverable'), self::ALL));
+ }
+
+ public function test_unknown_never_blocks(): void
+ {
+ // Fail open on uncertainty even with every mode enabled.
+ $this->assertFalse(EmailValidator::shouldBlock(EmailValidator::result('unknown'), self::ALL));
+ }
+
+ public function test_no_modes_enabled_never_blocks(): void
+ {
+ $this->assertFalse(EmailValidator::shouldBlock(EmailValidator::result('undeliverable', disposable: true), self::NONE));
+ }
+
+ public function test_infra_failures_are_not_definitive(): void
+ {
+ $this->assertFalse(EmailValidator::isDefinitive(EmailValidator::result('unknown', reason: 'missing_api_key')));
+ $this->assertFalse(EmailValidator::isDefinitive(EmailValidator::result('unknown', reason: 'http_error:timeout')));
+ $this->assertFalse(EmailValidator::isDefinitive(EmailValidator::result('unknown', reason: 'http_status:500')));
+ $this->assertFalse(EmailValidator::isDefinitive(EmailValidator::result('unknown', reason: 'malformed_response')));
+ }
+
+ public function test_real_verdicts_are_definitive(): void
+ {
+ $this->assertTrue(EmailValidator::isDefinitive(EmailValidator::result('deliverable')));
+ $this->assertTrue(EmailValidator::isDefinitive(EmailValidator::result('undeliverable', reason: 'rejected_email')));
+ }
+}
diff --git a/test/unit/SpamFilterTest.php b/test/unit/SpamFilterTest.php
new file mode 100644
index 0000000..3b4d2a6
--- /dev/null
+++ b/test/unit/SpamFilterTest.php
@@ -0,0 +1,122 @@
+assertFalse($this->isSpam(''));
+ $this->assertFalse($this->isSpam(' '));
+ }
+
+ public function test_legitimate_message_is_not_spam(): void
+ {
+ $this->assertFalse($this->isSpam('Hei, tuotteenne oli loistava! Mistä saan lisää? Terveisin Matti'));
+ }
+
+ public function test_definite_keyword_flags_on_a_single_hit(): void
+ {
+ $this->assertTrue($this->isSpam('Buy VIAGRA now'));
+ $this->assertTrue($this->isSpam('Welcome to the best CASINO online'));
+ }
+
+ public function test_keyword_does_not_match_inside_a_word(): void
+ {
+ // Word-boundary matching: "casino" must not trip on a substring.
+ $this->assertFalse(SpamFilter::contentIsSpam('the cppcasinoxx token', '', ['casino'], self::THRESHOLD));
+ // But a real word boundary (punctuation) still matches.
+ $this->assertTrue(SpamFilter::contentIsSpam('visit the casino.', '', ['casino'], self::THRESHOLD));
+ }
+
+ public function test_keyword_evasion_with_zero_width_chars_still_caught(): void
+ {
+ $this->assertTrue($this->isSpam("vi\u{200B}agra")); // zero-width space inside the word
+ }
+
+ public function test_one_or_two_links_are_allowed(): void
+ {
+ $this->assertFalse($this->isSpam('See our recipe at https://example.com, looks great!'));
+ $this->assertFalse($this->isSpam('http://a.com and http://b.com'));
+ }
+
+ public function test_three_links_alone_do_not_flag(): void
+ {
+ // Score 2 stays below the threshold — needs a second signal.
+ $this->assertFalse($this->isSpam('http://a.com http://b.com http://c.com'));
+ }
+
+ public function test_many_links_alone_flag(): void
+ {
+ $this->assertTrue($this->isSpam('http://a.com http://b.com http://c.com http://d.com http://e.com'));
+ // Scheme-less www. links are counted too.
+ $this->assertTrue($this->isSpam('www.a.com www.b.com www.c.com www.d.com www.e.com'));
+ }
+
+ public function test_a_lone_foreign_word_is_allowed(): void
+ {
+ $this->assertFalse($this->isSpam('Спасибо'));
+ }
+
+ public function test_two_combined_signals_flag(): void
+ {
+ // Wrong-script text (2) + link farm of 3 (2) = 4 >= 3.
+ $this->assertTrue($this->isSpam('Спасибо http://a.com http://b.com http://c.com'));
+ }
+
+ public function test_url_in_name_field_flags(): void
+ {
+ // A link in the identity (name) field alone is enough.
+ $this->assertTrue($this->isSpam('Hello, nice site', 'http://spam.example'));
+ // …while a clean name with an ordinary message does not.
+ $this->assertFalse($this->isSpam('Hello, nice site', 'Matti Meikäläinen'));
+ }
+
+ public function test_keywords_are_case_insensitive(): void
+ {
+ $this->assertTrue($this->isSpam('ViAgRa'));
+ }
+
+ public function test_resolve_ip_uses_remote_addr_by_default(): void
+ {
+ $this->assertSame('203.0.113.9', SpamFilter::resolveIp(['REMOTE_ADDR' => '203.0.113.9'], ['REMOTE_ADDR']));
+ }
+
+ public function test_resolve_ip_respects_header_precedence(): void
+ {
+ $server = ['HTTP_CF_CONNECTING_IP' => '198.51.100.7', 'REMOTE_ADDR' => '10.0.0.1'];
+ $this->assertSame('198.51.100.7', SpamFilter::resolveIp($server, ['HTTP_CF_CONNECTING_IP', 'REMOTE_ADDR']));
+ }
+
+ public function test_resolve_ip_takes_first_of_a_forwarded_list(): void
+ {
+ $server = ['HTTP_X_FORWARDED_FOR' => '203.0.113.5, 70.41.3.18, 150.172.238.178'];
+ $this->assertSame('203.0.113.5', SpamFilter::resolveIp($server, ['HTTP_X_FORWARDED_FOR']));
+ }
+
+ public function test_resolve_ip_skips_invalid_and_falls_through(): void
+ {
+ $server = ['HTTP_X_REAL_IP' => 'not-an-ip', 'REMOTE_ADDR' => '203.0.113.9'];
+ $this->assertSame('203.0.113.9', SpamFilter::resolveIp($server, ['HTTP_X_REAL_IP', 'REMOTE_ADDR']));
+ }
+
+ public function test_resolve_ip_returns_null_when_nothing_valid(): void
+ {
+ $this->assertNull(SpamFilter::resolveIp(['REMOTE_ADDR' => 'garbage'], ['REMOTE_ADDR']));
+ $this->assertNull(SpamFilter::resolveIp([], ['REMOTE_ADDR']));
+ }
+}