From fdf83a94945d237757207071dad284e8ddb285d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Tue, 2 Jun 2026 12:19:11 -0300 Subject: [PATCH 1/8] feat: stronger PoW (presets), replay protection, and opt-in spam layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the opt-in settings with several anti-bot improvements (0.3.0): - Proof-of-work cost is now a "Protection strength" preset dropdown (Low/Standard/High/Very high), settable site-wide and per form, defaulting to a 20x stronger Standard (200k). Exact values still available via the `genero/gravityforms_altcha/cost` filter. - Replay protection: each solved challenge is single-use (deduped on its unique signature for the rest of its lifetime), so a proof can't be replayed across many submissions. - Optional, independent spam layers (global toggles, applied via gform_entry_is_spam so flagged entries are marked spam — recoverable — never rejected): - Rate limiting (default 2/min/IP). IP resolved independently of GF (which sites often blank for GDPR) and kept only as a salted HMAC in a 60s transient; never stores the raw IP. CDN client-IP header is configurable. - Content heuristics: definite-spam keywords flag immediately; weaker signals (link farms, injected markup, wrong-script text) must combine to flag. Unit tests cover the fingerprint, cost clamp, IP resolver, and content scoring. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 78 +++++++++++++ gravityforms-altcha.php | 2 +- src/Challenge.php | 82 +++++++++++++- src/ChallengeEndpoint.php | 23 +++- src/Integration.php | 44 +++++++- src/Plugin.php | 3 +- src/Settings.php | 98 ++++++++++++++++ src/SpamFilter.php | 213 +++++++++++++++++++++++++++++++++++ test/unit/ChallengeTest.php | 74 ++++++++++++ test/unit/SpamFilterTest.php | 95 ++++++++++++++++ 10 files changed, 700 insertions(+), 12 deletions(-) create mode 100644 src/SpamFilter.php create mode 100644 test/unit/SpamFilterTest.php diff --git a/README.md b/README.md index 7bcf459..1a8ac96 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,35 @@ 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 + per-minute allowance (default 2). 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, or accumulates enough weaker signals (link farms, + injected markup, wrong-script text). + ## How it works 1. **Form render** — when enabled for the form, the plugin injects a hidden @@ -61,6 +90,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,6 +135,51 @@ 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_per_minute` + +Per-IP, per-form submission allowance before a submission is flagged as spam +(default 2): + +```php +add_filter('genero/gravityforms_altcha/rate_limit_per_minute', fn (int $limit, array $form) => 5, 10, 2); +``` + +### `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); +``` + ## Development ```bash 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/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/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..89e4439 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,7 @@ public function registerHooks(): void Integration::register(); ChallengeEndpoint::register(); + SpamFilter::register(); } /** diff --git a/src/Settings.php b/src/Settings.php index 3a841ab..8bef3b9 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -68,6 +68,28 @@ 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 couple of times a minute. 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, + ], ], ], ]; @@ -92,11 +114,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 +171,47 @@ 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'); + } + + /** + * 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..733e964 --- /dev/null +++ b/src/SpamFilter.php @@ -0,0 +1,213 @@ + $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() + && self::contentIsSpam($this->submittedText($form, $entry), self::keywords(), self::scoreThreshold())) { + return true; + } + + return (bool) $isSpam; + } + + /** + * Per-IP, per-form sliding-ish counter. Allows up to the limit per minute + * 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 + * 60-second 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 per minute. + */ + $limit = max(1, (int) apply_filters('genero/gravityforms_altcha/rate_limit_per_minute', 2, $form)); + + $key = 'gfaltcha_rl_'.($form['id'] ?? 0).'_'.self::hashIp($ip); + $count = (int) get_transient($key); + set_transient($key, $count + 1, MINUTE_IN_SECONDS); + + return $count >= $limit; + } + + 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')); + } + + /** + * Concatenates the visitor-entered free-text fields for scoring. + * + * @param array $form + * @param array $entry + */ + private function submittedText(array $form, array $entry): string + { + $text = ''; + foreach ($form['fields'] ?? [] as $field) { + if (in_array($field->type ?? '', ['text', 'textarea'], true)) { + $text .= ' '.rgar($entry, (string) $field->id); + } + } + + return trim($text); + } + + /** + * 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. + * + * @param array $keywords + */ + public static function contentIsSpam(string $text, array $keywords, int $threshold): bool + { + if ($text === '') { + return false; + } + + foreach ($keywords as $keyword) { + if ($keyword !== '' && stripos($text, $keyword) !== false) { + return true; + } + } + + $score = 0; + if (preg_match_all('~https?://~i', $text) >= 3) { + $score += 2; // link farm + } + if (preg_match('~\[url=|= $threshold; + } + + /** + * @return array + */ + 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/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/SpamFilterTest.php b/test/unit/SpamFilterTest.php new file mode 100644 index 0000000..b5646cd --- /dev/null +++ b/test/unit/SpamFilterTest.php @@ -0,0 +1,95 @@ +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_a_single_link_is_allowed(): void + { + $this->assertFalse($this->isSpam('See our recipe at https://example.com, looks great!')); + } + + public function test_three_links_alone_do_not_flag(): void + { + // One weak signal (score 2) stays below the threshold — err toward real users. + $this->assertFalse($this->isSpam('http://a.com and http://b.com and http://c.com')); + } + + public function test_a_lone_foreign_word_is_allowed(): void + { + $this->assertFalse($this->isSpam('Спасибо')); + } + + public function test_two_combined_signals_flag(): void + { + // Link farm (2) + injected anchor markup (2) = 4 >= 3. + $this->assertTrue($this->isSpam('http://a.com http://b.com http://c.com x')); + // Wrong-script text (2) + link farm (2) = 4 >= 3. + $this->assertTrue($this->isSpam('Спасибо http://a.com http://b.com http://c.com')); + } + + 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'])); + } +} From e818f19cb9fe19ef5f39f57d1e92189897ba125c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Tue, 2 Jun 2026 12:24:04 -0300 Subject: [PATCH 2/8] tune: rate-limit default to 3 per hour with configurable window Replaces the fixed per-minute allowance with a configurable max + window (genero/gravityforms_altcha/rate_limit_max + _window), defaulting to 3 per hour. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 11 ++++++----- src/Settings.php | 2 +- src/SpamFilter.php | 19 ++++++++++++------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1a8ac96..c74592e 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ blocked or shown an error, and a false positive stays recoverable in the entry itself is enabled. * **Rate limiting** — flags submissions once an IP exceeds a per-form - per-minute allowance (default 2). The IP is resolved independently of Gravity + 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 @@ -161,13 +161,14 @@ add_filter('genero/gravityforms_altcha/client_ip_headers', fn () => [ 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_per_minute` +### `genero/gravityforms_altcha/rate_limit_max` and `…/rate_limit_window` -Per-IP, per-form submission allowance before a submission is flagged as spam -(default 2): +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_per_minute', fn (int $limit, array $form) => 5, 10, 2); +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` diff --git a/src/Settings.php b/src/Settings.php index 8bef3b9..ddd8558 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -80,7 +80,7 @@ public function plugin_settings_fields() '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 couple of times a minute. Applies to all Gravity Forms.', '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, ], [ diff --git a/src/SpamFilter.php b/src/SpamFilter.php index 733e964..f242769 100644 --- a/src/SpamFilter.php +++ b/src/SpamFilter.php @@ -55,12 +55,13 @@ public function flag($isSpam, $form, $entry): bool } /** - * Per-IP, per-form sliding-ish counter. Allows up to the limit per minute - * and flags anything beyond it. An unresolvable IP is never penalised. + * 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 - * 60-second transient — the raw IP is never stored or logged. + * short-lived transient — the raw IP is never stored or logged. * * @param array $form */ @@ -72,15 +73,19 @@ private function exceedsRateLimit(array $form): bool } /** - * Filters the per-IP, per-form submission allowance per minute. + * 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. */ - $limit = max(1, (int) apply_filters('genero/gravityforms_altcha/rate_limit_per_minute', 2, $form)); + $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, MINUTE_IN_SECONDS); + set_transient($key, $count + 1, $window); - return $count >= $limit; + return $count >= $max; } private function clientIp(): ?string From 35ac4f0a314c770238ef3e53c623a54ed77e4736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Tue, 2 Jun 2026 12:26:15 -0300 Subject: [PATCH 3/8] feat: optional email validation via Bouncer (off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Email validation" toggle that rejects undeliverable or disposable email addresses at the field with a corrective message, so visitors fix a bad address instead of silently never being reachable. Verifies via the Bouncer API (BOUNCER_API_KEY env var, or the bouncer_api_key filter). Fails open: risky/unknown verdicts, a missing key, or any API error never block. Definitive verdicts are cached for a day keyed by a hash of the email. Sends the email to a third-party service — document in your privacy policy. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 26 ++++ src/EmailValidator.php | 208 +++++++++++++++++++++++++++++++ src/Plugin.php | 1 + src/Settings.php | 12 ++ test/unit/EmailValidatorTest.php | 48 +++++++ 5 files changed, 295 insertions(+) create mode 100644 src/EmailValidator.php create mode 100644 test/unit/EmailValidatorTest.php diff --git a/README.md b/README.md index c74592e..3eacee9 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,13 @@ itself is enabled. * **Content spam filtering** — flags submissions whose text contains a definite-spam keyword, or accumulates enough weaker signals (link farms, injected markup, wrong-script text). +* **Email validation** — unlike the two above, this *blocks* the field (with a + corrective message) when the email is undeliverable or disposable, so the + visitor fixes a bad address rather than silently never hearing back. Verifies + via [Bouncer](https://usebouncer.com); requires the `BOUNCER_API_KEY` + environment variable. **Fails open** — risky/unknown verdicts, 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 @@ -181,6 +188,25 @@ add_filter('genero/gravityforms_altcha/spam_keywords', fn (array $words) => [... 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 diff --git a/src/EmailValidator.php b/src/EmailValidator.php new file mode 100644 index 0000000..0c0b9b6 --- /dev/null +++ b/src/EmailValidator.php @@ -0,0 +1,208 @@ + $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::validate($email)['block']) { + 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, block: bool} + */ + 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. Only a definitive undeliverable/disposable blocks. + * + * @return array{status: string, disposable: bool, role: bool, reason: ?string, block: bool} + */ + public static function result(string $status, bool $disposable = false, bool $role = false, ?string $reason = null): array + { + $block = $disposable || $status === 'undeliverable'; + + return compact('status', 'disposable', 'role', 'reason', 'block'); + } + + /** + * 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, block: bool} + */ + 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/Plugin.php b/src/Plugin.php index 89e4439..0ce14b1 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -66,6 +66,7 @@ public function registerHooks(): void Integration::register(); ChallengeEndpoint::register(); SpamFilter::register(); + EmailValidator::register(); } /** diff --git a/src/Settings.php b/src/Settings.php index ddd8558..3f8c449 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -90,6 +90,13 @@ public function plugin_settings_fields() '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 an undeliverable or disposable 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, + ], ], ], ]; @@ -182,6 +189,11 @@ 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'); + } + /** * 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 diff --git a/test/unit/EmailValidatorTest.php b/test/unit/EmailValidatorTest.php new file mode 100644 index 0000000..d504801 --- /dev/null +++ b/test/unit/EmailValidatorTest.php @@ -0,0 +1,48 @@ +assertTrue(EmailValidator::result('undeliverable')['block']); + } + + public function test_disposable_blocks_even_when_deliverable(): void + { + $this->assertTrue(EmailValidator::result('deliverable', disposable: true)['block']); + } + + public function test_deliverable_does_not_block(): void + { + $this->assertFalse(EmailValidator::result('deliverable')['block']); + } + + public function test_uncertain_verdicts_never_block(): void + { + // Fail open: risky/unknown must let the submission through. + $this->assertFalse(EmailValidator::result('risky')['block']); + $this->assertFalse(EmailValidator::result('unknown')['block']); + } + + public function test_infra_failures_are_not_definitive(): void + { + // These should NOT be cached as a verdict. + $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'))); + } +} From 738ef6ad44cdb26d1f605bc178b1d5d66fa18191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Tue, 2 Jun 2026 12:30:18 -0300 Subject: [PATCH 4/8] feat: per-verdict checkboxes for email validation (undeliverable/risky/disposable) Replaces the hardcoded "undeliverable or disposable blocks" with admin checkboxes under the Email validation toggle, so each Bouncer verdict can be acted on independently. Defaults: undeliverable + disposable on, risky off (risky can reject some real catch-all/role addresses). Unchecked/unset verdicts never block. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 +++++---- src/EmailValidator.php | 54 +++++++++++++++++++++++--------- src/Settings.php | 45 +++++++++++++++++++++++++- test/unit/EmailValidatorTest.php | 41 +++++++++++++++++------- 4 files changed, 122 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3eacee9..c1b14ac 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,14 @@ itself is enabled. definite-spam keyword, or accumulates enough weaker signals (link farms, injected markup, wrong-script text). * **Email validation** — unlike the two above, this *blocks* the field (with a - corrective message) when the email is undeliverable or disposable, so the - visitor fixes a bad address rather than silently never hearing back. Verifies - via [Bouncer](https://usebouncer.com); requires the `BOUNCER_API_KEY` - environment variable. **Fails open** — risky/unknown verdicts, 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. + 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 diff --git a/src/EmailValidator.php b/src/EmailValidator.php index 0c0b9b6..1dfbc9d 100644 --- a/src/EmailValidator.php +++ b/src/EmailValidator.php @@ -5,15 +5,16 @@ /** * Optional real-time email validation for Gravity Forms email fields, via the * Bouncer API (https://usebouncer.com). When enabled, submissions whose email - * is undeliverable or disposable are rejected at the field with a corrective - * message — unlike the spam layers, this *blocks* because the right outcome is - * to help the visitor fix a bad address (otherwise you could never reply). + * matches one of the admin-selected verdicts are rejected at the field with a + * corrective message — unlike the spam layers, this *blocks* because the right + * outcome is to help the visitor fix a bad address (otherwise you could never + * reply). * * Safety: - * - Off by default; controlled by a global toggle in {@see Settings}. - * - Fails open — any uncertainty (risky/unknown), a missing API key, or an API - * error never blocks the submission. Only a definitive "undeliverable" or - * "disposable" verdict does. + * - Off by default; controlled by a global toggle in {@see Settings}, with + * per-verdict checkboxes (undeliverable / risky / disposable). + * - Fails open — `unknown`, a missing API key, or an API error never blocks, + * and only the verdicts you tick are acted on. * - Caches definitive verdicts for a day, keyed by a hash of the email. * * Privacy: enabling this sends the submitted email address to Bouncer, a @@ -68,7 +69,7 @@ public function validateField($result, $value, $form, $field) return $result; } - if (! self::validate($email)['block']) { + if (! self::shouldBlock(self::validate($email), Settings::emailBlockModes())) { return $result; } @@ -86,7 +87,7 @@ public function validateField($result, $value, $form, $field) } /** - * @return array{status: string, disposable: bool, role: bool, reason: ?string, block: bool} + * @return array{status: string, disposable: bool, role: bool, reason: ?string} */ public static function validate(string $email): array { @@ -129,15 +130,40 @@ public static function isDefinitive(array $result): bool } /** - * Normalised verdict. Only a definitive undeliverable/disposable blocks. + * 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, block: bool} + * @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 { - $block = $disposable || $status === 'undeliverable'; + 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 compact('status', 'disposable', 'role', 'reason', 'block'); + return false; } /** @@ -145,7 +171,7 @@ public static function result(string $status, bool $disposable = false, bool $ro * * @see https://docs.usebouncer.com/api-reference/real-time/verify-email * - * @return array{status: string, disposable: bool, role: bool, reason: ?string, block: bool} + * @return array{status: string, disposable: bool, role: bool, reason: ?string} */ private static function bouncer(string $email): array { diff --git a/src/Settings.php b/src/Settings.php index 3f8c449..971d6c1 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -94,9 +94,35 @@ public function plugin_settings_fields() 'name' => 'enable_email_validation', 'type' => 'toggle', 'label' => esc_html__('Email validation', 'gravityforms-altcha'), - 'tooltip' => esc_html__('Asks the visitor to correct an undeliverable or disposable 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'), + '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, + ], + ], + ], ], ], ]; @@ -194,6 +220,23 @@ 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 diff --git a/test/unit/EmailValidatorTest.php b/test/unit/EmailValidatorTest.php index d504801..d3c0af4 100644 --- a/test/unit/EmailValidatorTest.php +++ b/test/unit/EmailValidatorTest.php @@ -9,31 +9,50 @@ class EmailValidatorTest extends TestCase { - public function test_undeliverable_blocks(): void + private const ALL = ['undeliverable' => 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 { - $this->assertTrue(EmailValidator::result('undeliverable')['block']); + $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_disposable_blocks_even_when_deliverable(): void + public function test_deliverable_never_blocks(): void { - $this->assertTrue(EmailValidator::result('deliverable', disposable: true)['block']); + $this->assertFalse(EmailValidator::shouldBlock(EmailValidator::result('deliverable'), self::ALL)); } - public function test_deliverable_does_not_block(): void + public function test_unknown_never_blocks(): void { - $this->assertFalse(EmailValidator::result('deliverable')['block']); + // Fail open on uncertainty even with every mode enabled. + $this->assertFalse(EmailValidator::shouldBlock(EmailValidator::result('unknown'), self::ALL)); } - public function test_uncertain_verdicts_never_block(): void + public function test_no_modes_enabled_never_blocks(): void { - // Fail open: risky/unknown must let the submission through. - $this->assertFalse(EmailValidator::result('risky')['block']); - $this->assertFalse(EmailValidator::result('unknown')['block']); + $this->assertFalse(EmailValidator::shouldBlock(EmailValidator::result('undeliverable', disposable: true), self::NONE)); } public function test_infra_failures_are_not_definitive(): void { - // These should NOT be cached as a verdict. $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'))); From e30ddc823beee636414fdaed70ef935ace32ac73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Tue, 2 Jun 2026 12:40:15 -0300 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20stronger,=20safer=20content=20heuri?= =?UTF-8?q?stics=20(audit=20follow-ups=201=E2=80=935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scan all visitor free text, including composite name/address sub-inputs (previously only top-level text/textarea — the Name field, where bots love to dump links, was invisible). - A URL in the name field is treated as near-certain spam. - Count scheme-less www. links and graduate link scoring (1–2 innocent, 3–4 a signal, 5+ damning) without double-counting http://www. - Match definite keywords on unicode word boundaries (no Scunthorpe false positives if a short keyword is configured). - Normalise zero-width / invisible characters before matching to defeat simple evasion. Single weak signals still never flag alone, so false positives stay near zero. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- src/SpamFilter.php | 127 ++++++++++++++++++++++++++++++----- test/unit/SpamFilterTest.php | 43 +++++++++--- 3 files changed, 149 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c1b14ac..3561315 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,10 @@ itself is enabled. 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, or accumulates enough weaker signals (link farms, - injected markup, wrong-script text). + 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 — diff --git a/src/SpamFilter.php b/src/SpamFilter.php index f242769..8eac3a6 100644 --- a/src/SpamFilter.php +++ b/src/SpamFilter.php @@ -46,9 +46,11 @@ public function flag($isSpam, $form, $entry): bool return true; } - if (Settings::contentFilterEnabled() - && self::contentIsSpam($this->submittedText($form, $entry), self::keywords(), self::scoreThreshold())) { - 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; @@ -142,57 +144,148 @@ public static function hashIp(string $ip): string } /** - * Concatenates the visitor-entered free-text fields for scoring. + * 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 submittedText(array $form, array $entry): string + private function extractText(array $form, array $entry): array { - $text = ''; + $bodyTypes = ['text', 'textarea', 'name', 'address', 'website', 'post_title', 'post_content', 'post_excerpt']; + + $body = ''; + $identity = ''; + foreach ($form['fields'] ?? [] as $field) { - if (in_array($field->type ?? '', ['text', 'textarea'], true)) { - $text .= ' '.rgar($entry, (string) $field->id); + $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 trim($text); + 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. + * 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 $text, array $keywords, int $threshold): bool + public static function contentIsSpam(string $body, string $identity, array $keywords, int $threshold): bool { - if ($text === '') { + $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 !== '' && stripos($text, $keyword) !== false) { + if ($keyword !== '' && self::containsWord($combined, $keyword)) { return true; } } $score = 0; - if (preg_match_all('~https?://~i', $text) >= 3) { - $score += 2; // link farm + + // 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=|= $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('~(? */ diff --git a/test/unit/SpamFilterTest.php b/test/unit/SpamFilterTest.php index b5646cd..3b4d2a6 100644 --- a/test/unit/SpamFilterTest.php +++ b/test/unit/SpamFilterTest.php @@ -13,9 +13,9 @@ class SpamFilterTest extends TestCase private const THRESHOLD = 3; - private function isSpam(string $text): bool + private function isSpam(string $body, string $identity = ''): bool { - return SpamFilter::contentIsSpam($text, self::KEYWORDS, self::THRESHOLD); + return SpamFilter::contentIsSpam($body, $identity, self::KEYWORDS, self::THRESHOLD); } public function test_empty_text_is_never_spam(): void @@ -35,15 +35,36 @@ public function test_definite_keyword_flags_on_a_single_hit(): void $this->assertTrue($this->isSpam('Welcome to the best CASINO online')); } - public function test_a_single_link_is_allowed(): void + 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 { - // One weak signal (score 2) stays below the threshold — err toward real users. - $this->assertFalse($this->isSpam('http://a.com and http://b.com and http://c.com')); + // 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 @@ -53,12 +74,18 @@ public function test_a_lone_foreign_word_is_allowed(): void public function test_two_combined_signals_flag(): void { - // Link farm (2) + injected anchor markup (2) = 4 >= 3. - $this->assertTrue($this->isSpam('http://a.com http://b.com http://c.com x')); - // Wrong-script text (2) + link farm (2) = 4 >= 3. + // 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')); From 3008a77dabfc3aa00b66f6dc52a88e3d5c268068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Tue, 2 Jun 2026 12:55:19 -0300 Subject: [PATCH 6/8] test: add mocked CI tests + real-WP integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the suite into three layers, mirroring the house pattern (gds-mcp): - unit: pure logic (existing), runs everywhere. - mocked: WP-glue tested with WordPress functions stubbed via Brain\Monkey — EmailValidator's Bouncer HTTP→verdict mapping/caching/fail-open, the rate-limit counter + unknown-IP guard, and replay dedup. Runs in CI (no DB, no GF) — `composer test` now covers unit + mocked. - integration: real WordPress via wp-phpunit (`composer test:integration`), exercising the GF settings, the gform_entry_is_spam spam layers, and cost resolution against an actual Gravity Forms install. GF is commercial, so these skip when it's absent (CI) and run via wp-env / DDEV. Pins phpunit to ^9 (wp-phpunit requirement) and adds a wp-env CI job that smoke-loads the plugin in real WordPress. .wp-env.json mounts a sibling gravityforms checkout for local integration runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 31 + .gitignore | 1 + .wp-env.json | 14 + .wp-env.override.json.ci | 5 + README.md | 23 +- composer.json | 17 +- composer.lock | 962 +++++++++++++----- phpunit.integration.xml.dist | 15 + phpunit.xml.dist | 12 +- test/bootstrap-integration.php | 35 + test/bootstrap.php | 13 + test/integration/IntegrationTestCase.php | 21 + test/integration/SettingsIntegrationTest.php | 79 ++ .../integration/SpamFilterIntegrationTest.php | 99 ++ test/mocked/EmailValidatorBouncerTest.php | 142 +++ test/mocked/MockedTestCase.php | 27 + test/mocked/RateLimitTest.php | 83 ++ test/mocked/ReplayTest.php | 79 ++ 18 files changed, 1415 insertions(+), 243 deletions(-) create mode 100644 .wp-env.json create mode 100644 .wp-env.override.json.ci create mode 100644 phpunit.integration.xml.dist create mode 100644 test/bootstrap-integration.php create mode 100644 test/bootstrap.php create mode 100644 test/integration/IntegrationTestCase.php create mode 100644 test/integration/SettingsIntegrationTest.php create mode 100644 test/integration/SpamFilterIntegrationTest.php create mode 100644 test/mocked/EmailValidatorBouncerTest.php create mode 100644 test/mocked/MockedTestCase.php create mode 100644 test/mocked/RateLimitTest.php create mode 100644 test/mocked/ReplayTest.php diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 042892e..421fb93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,37 @@ jobs: - run: composer test + integration: + name: Integration (wp-env) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - run: composer install --no-interaction --prefer-dist + + # Gravity Forms is commercial and unavailable in CI, so mount only this + # plugin. Its GF-dependent tests skip; the suite still smoke-tests that the + # plugin loads and boots inside a real WordPress. + - name: Configure wp-env for CI + run: cp .wp-env.override.json.ci .wp-env.override.json + + - name: Start wp-env + run: npx @wordpress/env start + + - name: Run integration tests + run: >- + npx @wordpress/env run tests-cli + --env-cwd=wp-content/plugins/gravityforms-altcha + vendor/bin/phpunit -c phpunit.integration.xml.dist + build: name: Build widget bundle runs-on: ubuntu-latest 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/.wp-env.override.json.ci b/.wp-env.override.json.ci new file mode 100644 index 0000000..0873105 --- /dev/null +++ b/.wp-env.override.json.ci @@ -0,0 +1,5 @@ +{ + "plugins": [ + "." + ] +} diff --git a/README.md b/README.md index 3561315..02e4b16 100644 --- a/README.md +++ b/README.md @@ -217,10 +217,31 @@ add_filter('genero/gravityforms_altcha/email_error_message', fn () => __('That e 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..ba0cb9d 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,20 @@ }, "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": { + "allow-plugins": { + "composer/installers": true + } } } diff --git a/composer.lock b/composer.lock index 8a8acb0..b89f846 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": "e1279d7129baab0d1a7030a3b28a6199", "packages": [ { "name": "altcha-org/altcha", @@ -55,6 +55,244 @@ } ], "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.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "shasum": "" + }, + "require": { + "php": "^8.4" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" + }, + "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.1.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": "2026-01-05T06:47:08+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 +361,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 +682,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 +699,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 +719,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1.x-dev" + "dev-main": "9.2.x-dev" } }, "autoload": { @@ -427,7 +748,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 +756,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 +808,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 +816,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 +845,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-master": "3.1-dev" } }, "autoload": { @@ -551,7 +871,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 +879,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 +930,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 +938,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 +989,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 +997,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 +1024,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 +1052,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.5-dev" + "dev-master": "9.6-dev" } }, "autoload": { @@ -763,7 +1084,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 +1108,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 +1156,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 +1164,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 +1212,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 +1220,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 +1267,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 +1275,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 +1341,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 +1361,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 +1410,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 +1418,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 +1476,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 +1484,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 +1512,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.1-dev" + "dev-master": "5.1-dev" } }, "autoload": { @@ -1216,7 +1531,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 +1539,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 +1547,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 +1616,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 +1636,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 +1686,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 +1761,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 +1769,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 +1818,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 +1826,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 +1873,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 +1881,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 +1936,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 +1956,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 +2058,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 +2066,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 +2111,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 +2119,7 @@ "type": "github" } ], - "time": "2023-02-07T11:34:05+00:00" + "time": "2020-09-28T06:39:44+00:00" }, { "name": "theseer/tokenizer", @@ -1791,6 +2170,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": [], 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/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')); + } +} From 87e3dca7b7f8f1b14a27e020b1c1e126df61c5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Tue, 2 Jun 2026 13:31:52 -0300 Subject: [PATCH 7/8] fix(ci): pin composer platform to php 8.2 so the lock installs across the matrix The lock was generated on PHP 8.4 and pinned doctrine/instantiator 2.1.0 (needs ^8.4), breaking composer install on the 8.2/8.3 CI runners. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 3 +++ composer.lock | 28 ++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index ba0cb9d..bac067e 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,9 @@ "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 b89f846..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": "e1279d7129baab0d1a7030a3b28a6199", + "content-hash": "1fa79ad8ee2d82ba9b0044069fe8e872", "packages": [ { "name": "altcha-org/altcha", @@ -175,29 +175,30 @@ }, { "name": "doctrine/instantiator", - "version": "2.1.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { - "php": "^8.4" + "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^14", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.58" + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "type": "library", "autoload": { @@ -224,7 +225,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.1.0" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { @@ -240,7 +241,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T06:47:08+00:00" + "time": "2022-12-30T00:23:10+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -2292,5 +2293,8 @@ "php": ">=8.2" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.2" + }, "plugin-api-version": "2.9.0" } From 1c164997ac357e4cafda3623df024dca5ce964a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Sch=C3=B6ldstr=C3=B6m?= Date: Tue, 2 Jun 2026 13:35:26 -0300 Subject: [PATCH 8/8] =?UTF-8?q?ci:=20drop=20wp-env=20integration=20job=20(?= =?UTF-8?q?GF=20unavailable=20in=20CI=20=E2=86=92=20all-skip;=20runs=20on?= =?UTF-8?q?=20DDEV/wp-env)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration suite needs Gravity Forms, which is commercial and absent in CI, so the job only smoke-booted wp-env and skipped every test — not worth the Docker flakiness. Integration runs locally via 'composer test:integration' (wp-env/DDEV). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 31 ------------------------------- .wp-env.override.json.ci | 5 ----- 2 files changed, 36 deletions(-) delete mode 100644 .wp-env.override.json.ci diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 421fb93..042892e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,37 +41,6 @@ jobs: - run: composer test - integration: - name: Integration (wp-env) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - - - run: composer install --no-interaction --prefer-dist - - # Gravity Forms is commercial and unavailable in CI, so mount only this - # plugin. Its GF-dependent tests skip; the suite still smoke-tests that the - # plugin loads and boots inside a real WordPress. - - name: Configure wp-env for CI - run: cp .wp-env.override.json.ci .wp-env.override.json - - - name: Start wp-env - run: npx @wordpress/env start - - - name: Run integration tests - run: >- - npx @wordpress/env run tests-cli - --env-cwd=wp-content/plugins/gravityforms-altcha - vendor/bin/phpunit -c phpunit.integration.xml.dist - build: name: Build widget bundle runs-on: ubuntu-latest diff --git a/.wp-env.override.json.ci b/.wp-env.override.json.ci deleted file mode 100644 index 0873105..0000000 --- a/.wp-env.override.json.ci +++ /dev/null @@ -1,5 +0,0 @@ -{ - "plugins": [ - "." - ] -}