pull/7309/head
Your Name 1 week ago
parent b7121d6118
commit 566c5503d3

@ -108,13 +108,15 @@ CAPTCHA_SITEKEY=
CAPTCHA_TURNSTILE_SITEKEY=
CAPTCHA_TURNSTILE_SECRET=
CAPTCHA_TURNSTILE_TIMEOUT=5
# Let requests through on network/5xx errors instead of blocking
CAPTCHA_TURNSTILE_FAIL_OPEN=false
# --- Cap, self-hosted proof-of-work (driver: cap) ---
# Full URL of your Cap instance including the site key (trailing slash required)
# Base URL WITHOUT the site key
CAP_ENDPOINT=
CAP_SITEKEY=
CAP_SECRET=
CAP_TOKEN_FIELD=cap-captcha-response
CAP_TIMEOUT=5
CAP_FAIL_OPEN=false
# @cap.js/widget version from jsDelivr; leave "latest" to track newest stable
CAP_WIDGET_VERSION=latest

@ -733,11 +733,14 @@ trait AdminSettingsController
ConfigCacheService::put('captcha.turnstile.sitekey', $request->input('captcha_turnstile_sitekey'));
}
// Cap credentials (endpoint is public, store as-is when present)
// Cap credentials (endpoint + sitekey are public, store as-is)
$putIfChanged('captcha.cap.secret', $request->input('captcha_cap_secret'));
if ($request->filled('captcha_cap_endpoint')) {
ConfigCacheService::put('captcha.cap.endpoint', $request->input('captcha_cap_endpoint'));
}
if ($request->filled('captcha_cap_sitekey')) {
ConfigCacheService::put('captcha.cap.sitekey', $request->input('captcha_cap_sitekey'));
}
ConfigCacheService::put('captcha.active.login', $request->boolean('captcha_on_login'));
ConfigCacheService::put('captcha.active.register', $request->boolean('captcha_on_register'));
@ -768,6 +771,7 @@ trait AdminSettingsController
'captcha_turnstile_secret' => $request->input('captcha_turnstile_secret'),
'captcha_turnstile_sitekey' => $request->input('captcha_turnstile_sitekey'),
'captcha_cap_endpoint' => $request->input('captcha_cap_endpoint'),
'captcha_cap_sitekey' => $request->input('captcha_cap_sitekey'),
'captcha_cap_secret' => $request->input('captcha_cap_secret'),
'custom_emoji_enabled' => $request->boolean('custom_emoji_enabled'),
];

@ -142,6 +142,7 @@ class AdminSettingsService
'captcha_turnstile_secret' => self::maskSecret(config_cache('captcha.turnstile.secret')),
'captcha_turnstile_sitekey' => config_cache('captcha.turnstile.sitekey'),
'captcha_cap_endpoint' => config_cache('captcha.cap.endpoint'),
'captcha_cap_sitekey' => config_cache('captcha.cap.sitekey'),
'captcha_cap_secret' => self::maskSecret(config_cache('captcha.cap.secret')),
'custom_emoji_enabled' => (bool) config_cache('federation.custom_emoji.enabled'),
];

@ -10,7 +10,12 @@ use LaravelCap\Cap;
* Cap driver (self-hosted proof-of-work CAPTCHA).
*
* Wraps the oliweb/laravel-cap package for verification, and renders the
* locally-published widget (public/vendor/cap/) so no external CDN is used.
* @cap.js/widget from the jsDelivr CDN.
*
* The full API endpoint the widget and verifier talk to is composed from a base
* URL (captcha.cap.endpoint) plus the site key (captcha.cap.sitekey):
*
* https://cap.example.com + 3c87a0e810 => https://cap.example.com/3c87a0e810/
*
* @see https://github.com/oliweb-ch/laravel-cap
*/
@ -30,6 +35,7 @@ class CapDriver implements CaptchaDriver
public function isConfigured(): bool
{
return ! empty(config_cache('captcha.cap.endpoint'))
&& ! empty(config_cache('captcha.cap.sitekey'))
&& ! empty(config_cache('captcha.cap.secret'));
}
@ -38,6 +44,24 @@ class CapDriver implements CaptchaDriver
return (string) config('captcha.cap.token_field', 'cap-token');
}
/**
* Compose the full Cap API endpoint: "{base}/{sitekey}/".
*
* The base URL is the instance origin without the site key. The site key is
* appended as a path segment with a trailing slash (required by Cap).
*/
public function apiEndpoint(): string
{
$base = rtrim(trim((string) config_cache('captcha.cap.endpoint')), '/');
$sitekey = trim((string) config_cache('captcha.cap.sitekey'), '/ ');
if ($base === '' || $sitekey === '') {
return '';
}
return $base.'/'.$sitekey.'/';
}
public function verify(array $input): bool
{
$token = $input[$this->responseField()] ?? null;
@ -46,8 +70,13 @@ class CapDriver implements CaptchaDriver
return false;
}
$endpoint = $this->apiEndpoint();
if ($endpoint === '') {
return false;
}
$cap = new Cap(app(HttpFactory::class), [
'endpoint' => config_cache('captcha.cap.endpoint'),
'endpoint' => $endpoint,
'secret' => config_cache('captcha.cap.secret'),
'timeout' => (int) config('captcha.cap.timeout', 5),
'fail_open' => (bool) config('captcha.cap.fail_open', false),
@ -58,7 +87,7 @@ class CapDriver implements CaptchaDriver
public function render(array $attributes = []): string
{
$endpoint = e((string) config_cache('captcha.cap.endpoint'));
$endpoint = e($this->apiEndpoint());
$field = e($this->responseField());
$attrs = '';

@ -115,6 +115,7 @@ class ConfigCacheService
'captcha.turnstile.secret',
'captcha.turnstile.sitekey',
'captcha.cap.endpoint',
'captcha.cap.sitekey',
'captcha.cap.secret',
'captcha.active.login',
'captcha.active.register',

@ -63,11 +63,13 @@ return [
|--------------------------------------------------------------------------
| Cap (self-hosted proof-of-work CAPTCHA)
|--------------------------------------------------------------------------
| The endpoint must include the site key and a trailing slash, e.g.
| https://cap.example.com/your-site-key/
| The endpoint is the instance base URL WITHOUT the site key, e.g.
| https://cap.example.com. The site key is a separate value; the full API
| endpoint (https://cap.example.com/your-site-key/) is composed by CapDriver.
*/
'cap' => [
'endpoint' => env('CAP_ENDPOINT'),
'sitekey' => env('CAP_SITEKEY'),
'secret' => env('CAP_SECRET'),
'token_field' => env('CAP_TOKEN_FIELD', 'cap-token'),
'timeout' => (int) env('CAP_TIMEOUT', 5),

@ -460,15 +460,26 @@
<!-- Cap credentials -->
<div class="row" v-else-if="platform.captcha_driver === 'cap'">
<div class="col-12 col-md-6">
<div class="col-12">
<div class="form-group my-1">
<label class="text-muted small">Cap Endpoint</label>
<input
type="text"
class="form-control"
name="captcha_cap_endpoint"
placeholder="https://cap.example.com/your-site-key/"
placeholder="https://cap.example.com"
v-model="platform.captcha_cap_endpoint">
<small class="form-text text-muted">Base URL of your Cap instance, without the site key.</small>
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-group my-1">
<label class="text-muted small">Cap Sitekey</label>
<input
type="text"
class="form-control"
name="captcha_cap_sitekey"
v-model="platform.captcha_cap_sitekey">
</div>
</div>
<div class="col-12 col-md-6">
@ -1496,6 +1507,7 @@
captcha_turnstile_secret: this.platform.captcha_turnstile_secret,
captcha_turnstile_sitekey: this.platform.captcha_turnstile_sitekey,
captcha_cap_endpoint: this.platform.captcha_cap_endpoint,
captcha_cap_sitekey: this.platform.captcha_cap_sitekey,
captcha_cap_secret: this.platform.captcha_cap_secret,
captcha_on_login: this.platform.captcha_on_login,
captcha_on_register: this.platform.captcha_on_register,

@ -0,0 +1,92 @@
<?php
namespace Tests\Feature;
use App\Services\AdminSettingsService;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Exercises the admin read path (AdminSettingsService::getPlatform) that feeds
* the captcha settings UI, including masking and provider-namespaced keys.
*/
class CaptchaAdminSettingsTest extends TestCase
{
#[Test]
public function platform_settings_expose_all_captcha_surface_toggles(): void
{
config([
'captcha.enabled' => true,
'captcha.driver' => 'turnstile',
'captcha.active.login' => true,
'captcha.active.register' => false,
'captcha.active.forgotpassword' => true,
'captcha.active.password_reset' => false,
'captcha.active.curated_register' => true,
]);
$platform = AdminSettingsService::getPlatform();
$this->assertTrue($platform['captcha_enabled']);
$this->assertSame('turnstile', $platform['captcha_driver']);
$this->assertTrue($platform['captcha_on_login']);
$this->assertFalse($platform['captcha_on_register']);
$this->assertTrue($platform['captcha_on_forgotpassword']);
$this->assertFalse($platform['captcha_on_password_reset']);
$this->assertTrue($platform['captcha_on_curated_register']);
}
#[Test]
public function platform_settings_expose_cap_endpoint_and_sitekey(): void
{
config([
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => '3c87a0e810',
]);
$platform = AdminSettingsService::getPlatform();
$this->assertArrayHasKey('captcha_cap_endpoint', $platform);
$this->assertArrayHasKey('captcha_cap_sitekey', $platform);
$this->assertSame('https://cap.example.com', $platform['captcha_cap_endpoint']);
$this->assertSame('3c87a0e810', $platform['captcha_cap_sitekey']);
}
#[Test]
public function secrets_are_masked_and_sitekeys_are_not(): void
{
config([
'captcha.hcaptcha.secret' => 'supersecretvalue',
'captcha.hcaptcha.sitekey' => 'publicsitekey123',
'captcha.turnstile.secret' => 'turnstilesecretvalue',
'captcha.turnstile.sitekey' => '0xPUBLICKEY',
'captcha.cap.secret' => 'capsecretvalue',
]);
$platform = AdminSettingsService::getPlatform();
// Secrets masked (contain the mask char, not the raw value).
$this->assertStringContainsString('*', $platform['captcha_hcaptcha_secret']);
$this->assertStringNotContainsString('supersecretvalue', $platform['captcha_hcaptcha_secret']);
$this->assertStringContainsString('*', $platform['captcha_turnstile_secret']);
$this->assertStringContainsString('*', $platform['captcha_cap_secret']);
// Public keys returned as-is (turnstile sitekey is not masked).
$this->assertSame('0xPUBLICKEY', $platform['captcha_turnstile_sitekey']);
}
#[Test]
public function mask_secret_tolerates_empty_and_null_values(): void
{
config([
'captcha.hcaptcha.secret' => null,
'captcha.turnstile.secret' => '',
]);
// Should not throw even when secrets are null/empty.
$platform = AdminSettingsService::getPlatform();
$this->assertArrayHasKey('captcha_hcaptcha_secret', $platform);
$this->assertArrayHasKey('captcha_turnstile_secret', $platform);
}
}

@ -6,6 +6,7 @@ use App\Services\Captcha\CapDriver;
use App\Services\Captcha\CaptchaManager;
use App\Services\Captcha\HCaptchaDriver;
use App\Services\Captcha\TurnstileDriver;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -18,6 +19,10 @@ class CaptchaManagerTest extends TestCase
return new CaptchaManager($this->app);
}
// ---------------------------------------------------------------------
// Driver resolution
// ---------------------------------------------------------------------
#[Test]
public function it_resolves_the_hcaptcha_driver_by_default(): void
{
@ -30,25 +35,6 @@ class CaptchaManagerTest extends TestCase
$this->assertSame('h-captcha-response', $driver->responseField());
}
#[Test]
public function hcaptcha_is_configured_from_namespaced_keys(): void
{
config([
'captcha.driver' => 'hcaptcha',
'captcha.hcaptcha.secret' => 'a-real-secret',
'captcha.hcaptcha.sitekey' => 'a-real-sitekey',
]);
$this->assertTrue($this->manager()->active()->isConfigured());
// Placeholder defaults should read as not configured.
config([
'captcha.hcaptcha.secret' => 'default_secret',
'captcha.hcaptcha.sitekey' => 'default_sitekey',
]);
$this->assertFalse($this->manager()->active()->isConfigured());
}
#[Test]
public function it_resolves_the_turnstile_driver(): void
{
@ -73,12 +59,65 @@ class CaptchaManagerTest extends TestCase
$this->assertSame('cap-token', $driver->responseField());
}
#[Test]
public function it_falls_back_to_hcaptcha_for_unknown_or_empty_driver(): void
{
config(['captcha.driver' => null]);
$this->assertInstanceOf(HCaptchaDriver::class, $this->manager()->active());
}
#[Test]
#[DataProvider('driverProvider')]
public function it_resolves_each_driver_and_field(string $driver, string $class, string $field): void
{
config(['captcha.driver' => $driver]);
$resolved = $this->manager()->active();
$this->assertInstanceOf($class, $resolved);
$this->assertSame($driver, $resolved->name());
$this->assertSame($field, $resolved->responseField());
}
public static function driverProvider(): array
{
return [
'hcaptcha' => ['hcaptcha', HCaptchaDriver::class, 'h-captcha-response'],
'turnstile' => ['turnstile', TurnstileDriver::class, 'cf-turnstile-response'],
'cap' => ['cap', CapDriver::class, 'cap-token'],
];
}
#[Test]
public function it_lists_available_drivers(): void
{
$this->assertSame(['hcaptcha', 'turnstile', 'cap'], $this->manager()->available());
}
#[Test]
public function rules_use_the_active_drivers_response_field(): void
{
config(['captcha.driver' => 'cap', 'captcha.cap.token_field' => 'cap-token']);
$this->assertSame(['cap-token' => 'required|captcha_verify'], $this->manager()->rules());
config(['captcha.driver' => 'turnstile']);
$this->assertArrayHasKey('cf-turnstile-response', $this->manager()->rules());
}
// ---------------------------------------------------------------------
// enabled() / activeOn()
// ---------------------------------------------------------------------
#[Test]
public function enabled_reflects_the_global_toggle(): void
{
config(['captcha.enabled' => true]);
$this->assertTrue($this->manager()->enabled());
config(['captcha.enabled' => false]);
$this->assertFalse($this->manager()->enabled());
}
#[Test]
public function active_on_requires_the_global_toggle(): void
{
@ -112,41 +151,56 @@ class CaptchaManagerTest extends TestCase
}
#[Test]
public function cap_widget_defaults_to_latest_when_no_version_set(): void
public function active_on_returns_false_for_an_unknown_surface(): void
{
config(['captcha.enabled' => true]);
$this->assertFalse($this->manager()->activeOn('does_not_exist'));
}
// ---------------------------------------------------------------------
// hCaptcha driver
// ---------------------------------------------------------------------
#[Test]
public function hcaptcha_is_configured_from_namespaced_keys(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.endpoint' => 'https://cap.example.com/site-key/',
'captcha.cap.widget_version' => null,
'captcha.driver' => 'hcaptcha',
'captcha.hcaptcha.secret' => 'a-real-secret',
'captcha.hcaptcha.sitekey' => 'a-real-sitekey',
]);
$scripts = $this->manager()->active()->scripts();
$this->assertStringContainsString('@cap.js/widget@latest', $scripts);
$this->assertTrue($this->manager()->active()->isConfigured());
}
#[Test]
public function cap_widget_loads_from_cdn_with_pinned_version(): void
public function hcaptcha_is_not_configured_with_placeholder_defaults(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.endpoint' => 'https://cap.example.com/site-key/',
'captcha.cap.widget_version' => '0.1.57',
'captcha.driver' => 'hcaptcha',
'captcha.hcaptcha.secret' => 'default_secret',
'captcha.hcaptcha.sitekey' => 'default_sitekey',
]);
$driver = $this->manager()->active();
$scripts = $driver->scripts();
$this->assertFalse($this->manager()->active()->isConfigured());
}
$this->assertStringContainsString('cdn.jsdelivr.net/npm/@cap.js/widget@0.1.57', $scripts);
// Must not reference self-hosted assets anymore.
$this->assertStringNotContainsString('vendor/cap/', $scripts);
#[Test]
public function hcaptcha_is_not_configured_when_keys_are_empty(): void
{
config([
'captcha.driver' => 'hcaptcha',
'captcha.hcaptcha.secret' => '',
'captcha.hcaptcha.sitekey' => '',
]);
$markup = $driver->render(['data-theme' => 'dark']);
$this->assertStringContainsString('<cap-widget', $markup);
$this->assertStringContainsString('data-cap-api-endpoint="https://cap.example.com/site-key/"', $markup);
$this->assertStringContainsString('data-cap-hidden-field-name="cap-token"', $markup);
$this->assertFalse($this->manager()->active()->isConfigured());
}
// ---------------------------------------------------------------------
// Turnstile driver
// ---------------------------------------------------------------------
#[Test]
public function turnstile_renders_widget_with_sitekey(): void
{
@ -160,4 +214,188 @@ class CaptchaManagerTest extends TestCase
$this->assertStringContainsString('cf-turnstile', $markup);
$this->assertStringContainsString('data-sitekey="0xTESTSITEKEY"', $markup);
}
#[Test]
public function turnstile_render_escapes_attributes(): void
{
config([
'captcha.driver' => 'turnstile',
'captcha.turnstile.sitekey' => '0xKEY',
]);
$markup = $this->manager()->active()->render(['data-theme' => '"><script>x']);
$this->assertStringNotContainsString('<script>x', $markup);
$this->assertStringContainsString('&lt;script&gt;', $markup);
}
#[Test]
public function turnstile_scripts_reference_cloudflare(): void
{
config(['captcha.driver' => 'turnstile']);
$this->assertStringContainsString(
'challenges.cloudflare.com/turnstile/v0/api.js',
$this->manager()->active()->scripts()
);
}
#[Test]
public function turnstile_is_configured_only_with_both_keys(): void
{
config(['captcha.driver' => 'turnstile']);
config(['captcha.turnstile.sitekey' => null, 'captcha.turnstile.secret' => null]);
$this->assertFalse($this->manager()->active()->isConfigured());
config(['captcha.turnstile.sitekey' => 'k', 'captcha.turnstile.secret' => null]);
$this->assertFalse($this->manager()->active()->isConfigured());
config(['captcha.turnstile.sitekey' => 'k', 'captcha.turnstile.secret' => 's']);
$this->assertTrue($this->manager()->active()->isConfigured());
}
// ---------------------------------------------------------------------
// Cap driver — endpoint composition (base URL + sitekey)
// ---------------------------------------------------------------------
#[Test]
public function cap_composes_the_api_endpoint_from_base_url_and_sitekey(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => '3c87a0e810',
]);
$this->assertSame(
'https://cap.example.com/3c87a0e810/',
$this->manager()->active()->apiEndpoint()
);
}
#[Test]
public function cap_endpoint_composition_normalizes_slashes(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.endpoint' => 'https://cap.example.com/',
'captcha.cap.sitekey' => '/abc/',
]);
$this->assertSame(
'https://cap.example.com/abc/',
$this->manager()->active()->apiEndpoint()
);
}
#[Test]
public function cap_api_endpoint_is_empty_when_pieces_are_missing(): void
{
config(['captcha.driver' => 'cap']);
config(['captcha.cap.endpoint' => 'https://cap.example.com', 'captcha.cap.sitekey' => null]);
$this->assertSame('', $this->manager()->active()->apiEndpoint());
config(['captcha.cap.endpoint' => null, 'captcha.cap.sitekey' => 'abc']);
$this->assertSame('', $this->manager()->active()->apiEndpoint());
}
#[Test]
public function cap_is_configured_only_with_endpoint_sitekey_and_secret(): void
{
config(['captcha.driver' => 'cap']);
config([
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => null,
'captcha.cap.secret' => 'sk',
]);
$this->assertFalse($this->manager()->active()->isConfigured());
config([
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => 'abc',
'captcha.cap.secret' => 'sk',
]);
$this->assertTrue($this->manager()->active()->isConfigured());
}
#[Test]
public function cap_render_uses_composed_endpoint_and_token_field(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => 'abc',
'captcha.cap.token_field' => 'cap-token',
]);
$markup = $this->manager()->active()->render(['data-theme' => 'dark']);
$this->assertStringContainsString('<cap-widget', $markup);
$this->assertStringContainsString('data-cap-api-endpoint="https://cap.example.com/abc/"', $markup);
$this->assertStringContainsString('data-cap-hidden-field-name="cap-token"', $markup);
$this->assertStringContainsString('data-theme="dark"', $markup);
}
#[Test]
public function cap_respects_a_custom_token_field(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.token_field' => 'my-cap-field',
]);
$driver = $this->manager()->active();
$this->assertSame('my-cap-field', $driver->responseField());
$this->assertStringContainsString('data-cap-hidden-field-name="my-cap-field"', $driver->render());
}
// ---------------------------------------------------------------------
// Cap driver — CDN widget script
// ---------------------------------------------------------------------
#[Test]
public function cap_widget_defaults_to_latest_when_no_version_set(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.widget_version' => null,
]);
$this->assertStringContainsString(
'@cap.js/widget@latest',
$this->manager()->active()->scripts()
);
}
#[Test]
public function cap_widget_defaults_to_latest_when_version_is_blank(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.widget_version' => ' ',
]);
$this->assertStringContainsString(
'@cap.js/widget@latest',
$this->manager()->active()->scripts()
);
}
#[Test]
public function cap_widget_loads_from_cdn_with_pinned_version(): void
{
config([
'captcha.driver' => 'cap',
'captcha.cap.widget_version' => '0.1.57',
]);
$scripts = $this->manager()->active()->scripts();
$this->assertStringContainsString('cdn.jsdelivr.net/npm/@cap.js/widget@0.1.57', $scripts);
// Must not reference the old self-hosted assets.
$this->assertStringNotContainsString('vendor/cap/', $scripts);
}
}

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Services\Captcha\CapDriver;
use App\Services\Captcha\TurnstileDriver;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
@ -10,6 +11,10 @@ use Tests\TestCase;
class CaptchaVerificationTest extends TestCase
{
// ---------------------------------------------------------------------
// captcha_verify validation rule (driver-agnostic)
// ---------------------------------------------------------------------
#[Test]
public function captcha_verify_rule_fails_when_token_is_missing(): void
{
@ -70,6 +75,69 @@ class CaptchaVerificationTest extends TestCase
$this->assertTrue($validator->fails());
}
#[Test]
public function captcha_verify_rule_uses_the_active_driver_field(): void
{
// With the cap driver active, the rule should validate against the
// cap-token field pulled from the request, not the attribute name.
config([
'captcha.driver' => 'cap',
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => 'abc',
'captcha.cap.secret' => 'sk',
]);
$this->app->forgetInstance('captcha.manager');
Http::fake([
'cap.example.com/*' => Http::response(['success' => true], 200),
]);
$validator = Validator::make(
['cap-token' => 'tok'],
['cap-token' => 'required|captcha_verify']
);
$this->assertTrue($validator->passes());
}
// ---------------------------------------------------------------------
// Turnstile driver verify()
// ---------------------------------------------------------------------
#[Test]
public function turnstile_verify_returns_false_for_empty_token_without_calling_out(): void
{
config(['captcha.turnstile.secret' => 'sekret']);
Http::fake();
$this->assertFalse((new TurnstileDriver)->verify([]));
$this->assertFalse((new TurnstileDriver)->verify(['cf-turnstile-response' => '']));
Http::assertNothingSent();
}
#[Test]
public function turnstile_verify_true_on_success_response(): void
{
config(['captcha.turnstile.secret' => 'sekret']);
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => true], 200),
]);
$this->assertTrue((new TurnstileDriver)->verify(['cf-turnstile-response' => 'tok']));
}
#[Test]
public function turnstile_verify_false_on_unsuccessful_response(): void
{
config(['captcha.turnstile.secret' => 'sekret']);
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => false, 'error-codes' => ['bad']], 200),
]);
$this->assertFalse((new TurnstileDriver)->verify(['cf-turnstile-response' => 'tok']));
}
#[Test]
public function turnstile_fail_open_lets_requests_through_on_network_error(): void
{
@ -77,14 +145,11 @@ class CaptchaVerificationTest extends TestCase
'captcha.turnstile.secret' => 'sekret',
'captcha.turnstile.fail_open' => true,
]);
Http::fake([
'challenges.cloudflare.com/*' => Http::response('boom', 500),
]);
$driver = new TurnstileDriver;
$this->assertTrue($driver->verify(['cf-turnstile-response' => 'anything']));
$this->assertTrue((new TurnstileDriver)->verify(['cf-turnstile-response' => 'anything']));
}
#[Test]
@ -94,21 +159,17 @@ class CaptchaVerificationTest extends TestCase
'captcha.turnstile.secret' => 'sekret',
'captcha.turnstile.fail_open' => false,
]);
Http::fake([
'challenges.cloudflare.com/*' => Http::response('boom', 500),
]);
$driver = new TurnstileDriver;
$this->assertFalse($driver->verify(['cf-turnstile-response' => 'anything']));
$this->assertFalse((new TurnstileDriver)->verify(['cf-turnstile-response' => 'anything']));
}
#[Test]
public function turnstile_sends_secret_and_response(): void
{
config(['captcha.turnstile.secret' => 'my-secret']);
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => true], 200),
]);
@ -121,4 +182,90 @@ class CaptchaVerificationTest extends TestCase
&& $request['response'] === 'my-token';
});
}
// ---------------------------------------------------------------------
// Cap driver verify() — hits {endpoint}/{sitekey}/siteverify
// ---------------------------------------------------------------------
#[Test]
public function cap_verify_returns_false_for_empty_token(): void
{
config([
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => 'abc',
'captcha.cap.secret' => 'sk',
]);
Http::fake();
$this->assertFalse((new CapDriver)->verify([]));
Http::assertNothingSent();
}
#[Test]
public function cap_verify_returns_false_when_not_configured(): void
{
config([
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => null, // missing -> apiEndpoint() empty
'captcha.cap.secret' => 'sk',
]);
Http::fake();
$this->assertFalse((new CapDriver)->verify(['cap-token' => 'tok']));
Http::assertNothingSent();
}
#[Test]
public function cap_verify_posts_to_composed_siteverify_url(): void
{
config([
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => '3c87a0e810',
'captcha.cap.secret' => 'sk-secret',
]);
Http::fake([
'cap.example.com/*' => Http::response(['success' => true], 200),
]);
$this->assertTrue((new CapDriver)->verify(['cap-token' => 'the-token']));
Http::assertSent(function ($request) {
return $request->url() === 'https://cap.example.com/3c87a0e810/siteverify';
});
}
#[Test]
public function cap_verify_false_when_server_rejects(): void
{
config([
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => 'abc',
'captcha.cap.secret' => 'sk',
]);
Http::fake([
'cap.example.com/*' => Http::response(['success' => false], 200),
]);
$this->assertFalse((new CapDriver)->verify(['cap-token' => 'tok']));
}
#[Test]
public function cap_verify_uses_custom_token_field(): void
{
config([
'captcha.cap.endpoint' => 'https://cap.example.com',
'captcha.cap.sitekey' => 'abc',
'captcha.cap.secret' => 'sk',
'captcha.cap.token_field' => 'my-token',
]);
Http::fake([
'cap.example.com/*' => Http::response(['success' => true], 200),
]);
$driver = new CapDriver;
// The custom field carries the token; the default name is ignored.
$this->assertTrue($driver->verify(['my-token' => 'tok']));
$this->assertFalse($driver->verify(['cap-token' => 'tok']));
}
}

Loading…
Cancel
Save