Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gravityforms-altcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.6.0
* Version: 0.6.1
* Requires at least: 6.0
* Requires PHP: 8.2
* Author: Genero
Expand Down
65 changes: 59 additions & 6 deletions src/Integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,23 @@ class Integration
*/
public const REASON_REPLAY = 'replay';

/**
* Form ids this request rejected, so the validation banner is only replaced
* for our own rejections and never for ordinary field errors.
*
* Validation and the re-render of the form happen within the same request
* for both standard and AJAX submissions, so per-instance state suffices.
*
* @var array<int, true>
*/
private array $rejected = [];

public static function register(): void
{
$instance = new self;
add_filter('gform_submit_button', [$instance, 'injectWidget'], 10, 2);
add_filter('gform_validation', [$instance, 'validate']);
add_filter('gform_validation_message', [$instance, 'validationMessage'], 10, 2);
add_action('gform_enqueue_scripts', [$instance, 'enqueueScripts'], 10, 2);
}

Expand Down Expand Up @@ -52,10 +64,12 @@ public function injectWidget(string $buttonInput, array $form): string

/**
* Rejects the submission when the proof is missing, malformed, or doesn't
* verify. We attach the error message to the form's `validation_summary`
* so themes that style validation errors at the form level surface it; we
* deliberately don't attach it to a per-field error since there's no
* visible field to point at.
* verify.
*
* There's no visible field to hang the error on, so the message is surfaced
* by replacing the form-level validation banner — see
* {@see self::validationMessage()}. We only note the form id here; the
* banner is rendered later in the same request.
*
* @param array{is_valid: bool, form: array<string, mixed>} $result
* @return array{is_valid: bool, form: array<string, mixed>}
Expand Down Expand Up @@ -87,12 +101,51 @@ public function validate(array $result): array
Logger::record('altcha', 'fail', $this->failureContext($formId, $reason, $payload, $challenge));

$result['is_valid'] = false;
$result['form']['validation_summary_message'] = $this->errorMessage();
$result['form']['failed_validation_page'] = $result['form']['page_count'] ?? 1;
$this->rejected[(int) $formId] = true;

return $result;
}

/**
* Replaces Gravity Forms' generic validation banner when *we* rejected the
* submission.
*
* GF's default banner reads "There was a problem with your submission.
* Please review the fields below." Because an ALTCHA rejection highlights
* no field, that instruction sends people hunting for a broken field that
* doesn't exist — which is exactly how this was once misdiagnosed as a
* form-configuration bug. Our message names the actual recovery action:
* reload and try again.
*
* Only fires for forms we rejected, so genuine field errors keep GF's
* wording.
*
* The markup mirrors `GFFormDisplay::get_validation_errors_markup()`,
* including how it derives the `hide_summary` class from the form's own
* `validationSummary` setting — hardcoding that class would override an
* admin's choice, and GF appends its (here empty) error list based on the
* same setting.
*
* @param string $message the markup GF built
* @param array<string, mixed> $form
*/
public function validationMessage(string $message, array $form): string
{
$formId = isset($form['id']) ? (int) $form['id'] : 0;

if (! isset($this->rejected[$formId])) {
return $message;
}

$hideSummary = empty($form['validationSummary']) ? ' hide_summary' : '';

return sprintf(
'<h2 class="gform_submission_error%s"><span class="gform-icon gform-icon--circle-error"></span>%s</h2>',
$hideSummary,
esc_html($this->errorMessage())
);
}

/**
* Builds the non-PII context recorded alongside a rejection.
*
Expand Down
2 changes: 1 addition & 1 deletion src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

class Plugin
{
public const VERSION = '0.6.0';
public const VERSION = '0.6.1';

public const SLUG = 'gravityforms-altcha';

Expand Down
41 changes: 41 additions & 0 deletions test/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,44 @@
defined('MINUTE_IN_SECONDS') || define('MINUTE_IN_SECONDS', 60);
defined('HOUR_IN_SECONDS') || define('HOUR_IN_SECONDS', 3600);
defined('DAY_IN_SECONDS') || define('DAY_IN_SECONDS', 86400);

/*
* Settings extends \GFAddOn, which only exists once Gravity Forms has loaded
* its add-on framework. Anything exercising Integration::shouldProtect() pulls
* Settings in, so give the class a shell to extend.
*
* Deliberately inert: it reports no settings, so a test needing a particular
* protection decision should force it through the documented
* `genero/gravityforms_altcha/should_protect` filter rather than leaning on
* whatever this stub happens to return.
*/
if (! class_exists('GFAddOn')) {
class GFAddOn
{
/** Declared by the real GFAddOn; Settings::__construct() assigns it. */
protected $_full_path;

public function __construct() {}

public static function register(string $class): void {}

/** @return mixed */
public function get_plugin_setting(string $name)
{
return null;
}

/**
* @param array<string, mixed> $form
* @return array<string, mixed>
*/
public function get_form_settings(array $form): array
{
return [];
}

public function log_debug(string $message): void {}

public function log_error(string $message): void {}
}
}
124 changes: 124 additions & 0 deletions test/mocked/ValidationMessageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

namespace Genero\GravityFormsAltcha\Tests\Mocked;

use Brain\Monkey\Functions;
use Genero\GravityFormsAltcha\Integration;
use Genero\GravityFormsAltcha\Plugin;

/**
* Exercises Integration::validationMessage() — the form-level banner shown when
* ALTCHA rejects a submission.
*
* Gravity Forms renders "There was a problem with your submission. Please review
* the fields below." for any invalid form. An ALTCHA rejection highlights no
* field, so that instruction points at nothing and sends people looking for a
* broken field that doesn't exist. These tests pin the replacement — and, just
* as importantly, that ordinary field errors keep GF's own wording.
*/
class ValidationMessageTest extends MockedTestCase
{
/** GF's default banner, near enough for assertion purposes. */
private const GF_DEFAULT = "<h2 class='gform_submission_error'>There was a problem with your submission. Please review the fields below.</h2>";

protected function setUp(): void
{
parent::setUp();

// Pass filters through to their default, except protection: the
// GFAddOn stub reports no settings, so force it on explicitly.
Functions\when('apply_filters')->alias(
fn (string $hook, $value = null) => $hook === 'genero/gravityforms_altcha/should_protect'
? true
: $value
);
Functions\when('plugin_dir_path')->returnArg(1);
Functions\when('plugin_dir_url')->returnArg(1);
Functions\when('get_option')->justReturn('0123456789abcdef0123456789abcdef');
Functions\when('esc_html')->returnArg(1);
Functions\when('__')->returnArg(1);
Functions\when('sanitize_text_field')->returnArg(1);
Functions\when('wp_unslash')->returnArg(1);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);

Plugin::getInstance(dirname(__DIR__, 2).'/gravityforms-altcha.php');
}

/**
* A form we never rejected must keep Gravity Forms' own banner — otherwise
* a plain "email is required" error would tell the visitor to reload the
* page, which is worse than useless.
*/
public function test_leaves_the_banner_alone_for_forms_it_did_not_reject(): void
{
$integration = new Integration;

$this->assertSame(
self::GF_DEFAULT,
$integration->validationMessage(self::GF_DEFAULT, ['id' => 45]),
);
}

public function test_replaces_the_banner_for_a_rejected_form(): void
{
$integration = $this->rejecting(45);

$message = $integration->validationMessage(self::GF_DEFAULT, ['id' => 45]);

$this->assertStringNotContainsString('Please review the fields below', $message);
$this->assertStringContainsString('could not be verified', $message);
}

/**
* Keeps GF's wrapper classes so existing theme styling still applies to the
* replacement.
*/
public function test_replacement_keeps_gravity_forms_markup_hooks(): void
{
$message = $this->rejecting(45)->validationMessage(self::GF_DEFAULT, ['id' => 45]);

$this->assertStringContainsString('gform_submission_error', $message);
$this->assertStringContainsString('gform-icon--circle-error', $message);
}

/**
* Two forms on one page: rejecting one must not rewrite the other's banner.
*/
public function test_only_the_rejected_form_is_affected(): void
{
$integration = $this->rejecting(45);

$this->assertSame(
self::GF_DEFAULT,
$integration->validationMessage(self::GF_DEFAULT, ['id' => 43]),
);
$this->assertStringContainsString(
'could not be verified',
$integration->validationMessage(self::GF_DEFAULT, ['id' => 45]),
);
}

/**
* Drives a real rejection through validate() so the test depends on the
* actual reject path rather than a hand-set flag.
*/
private function rejecting(int $formId): Integration
{
$integration = new Integration;

// No `altcha` field posted at all -> REASON_MISSING -> rejected.
unset($_POST[Integration::POST_FIELD]);

$result = $integration->validate([
'is_valid' => true,
'form' => ['id' => $formId],
]);

$this->assertFalse($result['is_valid'], 'expected validate() to reject the submission');

return $integration;
}
}
Loading