Files
projectsend/tests/Feature/Platform/CaptchaSettingsTest.php
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

272 lines
10 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Captcha\CaptchaForm;
use App\Modules\Platform\Captcha\CaptchaProvider;
use App\Modules\Platform\Captcha\CaptchaSettings;
use App\Modules\Platform\Captcha\CaptchaVerifier;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->admin = User::factory()->create();
// Settings and the display payload both outlive the per-test DB
// rollback, so nothing here may assume a default.
$settings = app(Settings::class);
$settings->set(Setting::CaptchaProvider, 'none');
$settings->set(Setting::CaptchaKeySource, 'own');
foreach (CaptchaForm::cases() as $form) {
$settings->set($form->setting(), true);
}
config()->set('projectsend.edition', Edition::Community);
config()->set('projectsend.captcha.disabled', false);
config()->set('projectsend.captcha.managed', ['provider' => null, 'site_key' => null, 'secret_key' => null, 'score_threshold' => 0.5]);
Captcha::forgetDisplayCache();
CaptchaVerifier::forgetOutage();
});
/** The payload the form posts, with only the interesting bits overridden. */
function captchaPayload(array $overrides = []): array
{
return array_merge([
'provider' => 'none',
'on_login' => true,
'on_registration' => true,
'on_password_reset' => true,
'on_public_comments' => true,
], $overrides);
}
test('the screen needs the settings permission', function () {
$this->actingAs(staffWithPermissions([]))->get('/system/settings/captcha')->assertForbidden();
$this->actingAs(staffWithPermissions(['edit_settings']))->get('/system/settings/captcha')->assertOk();
});
test('saving keys switches the captcha on', function () {
$this->actingAs($this->admin)
->patch('/system/settings/captcha', captchaPayload([
'provider' => 'turnstile',
'site_key' => 'site-abc',
'secret_key' => 'secret-abc',
]))
->assertRedirect();
$active = app(Captcha::class)->active();
expect($active)->not->toBeNull()
->and($active->provider)->toBe(CaptchaProvider::Turnstile)
->and($active->siteKey)->toBe('site-abc')
->and($active->secretKey)->toBe('secret-abc');
});
test('the secret is never sent to the browser, only the fact that there is one', function () {
CaptchaSettings::for(CaptchaProvider::Turnstile)->fill([
'site_key' => 'site-abc',
'secret_key' => 'secret-abc',
])->save();
app(Settings::class)->set(Setting::CaptchaProvider, 'turnstile');
Captcha::forgetDisplayCache();
$response = $this->actingAs($this->admin)->get('/system/settings/captcha');
$response->assertInertia(fn (AssertableInertia $page) => $page
->component('system/settings/captcha')
->where('providers.0.provider', 'turnstile')
->where('providers.0.site_key', 'site-abc')
->where('providers.0.has_secret_key', true)
);
expect($response->content())->not->toContain('secret-abc');
});
test('the secret is encrypted in the database', function () {
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
'provider' => 'turnstile',
'site_key' => 'site-abc',
'secret_key' => 'secret-abc',
]));
expect(DB::table('captcha_providers')->where('provider', 'turnstile')->value('secret_key'))
->not->toBe('secret-abc');
});
test('a blank secret keeps the stored one, so editing anything else does not wipe it', function () {
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
'provider' => 'turnstile',
'site_key' => 'site-abc',
'secret_key' => 'secret-abc',
]));
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
'provider' => 'turnstile',
'site_key' => 'site-changed',
'secret_key' => '',
'on_login' => false,
]));
$stored = CaptchaSettings::for(CaptchaProvider::Turnstile);
expect($stored->site_key)->toBe('site-changed')
->and($stored->secret_key)->toBe('secret-abc');
});
test('a provider cannot be switched on half-configured', function () {
$this->actingAs($this->admin)
->patch('/system/settings/captcha', captchaPayload(['provider' => 'turnstile', 'site_key' => '', 'secret_key' => '']))
->assertSessionHasErrors(['site_key', 'secret_key']);
});
test('a v3 threshold outside 0 to 1 is refused', function () {
$this->actingAs($this->admin)
->patch('/system/settings/captcha', captchaPayload([
'provider' => 'recaptcha_v3',
'site_key' => 'site-abc',
'secret_key' => 'secret-abc',
'score_threshold' => 1.5,
]))
->assertSessionHasErrors('score_threshold');
});
test('keys are kept per provider, so comparing two of them costs nothing', function () {
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
'provider' => 'turnstile', 'site_key' => 'turnstile-site', 'secret_key' => 'turnstile-secret',
]));
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
'provider' => 'recaptcha_v2', 'site_key' => 'google-site', 'secret_key' => 'google-secret',
]));
expect(CaptchaSettings::for(CaptchaProvider::Turnstile)->site_key)->toBe('turnstile-site')
->and(CaptchaSettings::for(CaptchaProvider::RecaptchaV2)->site_key)->toBe('google-site');
});
test('the per-form switches are saved', function () {
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
'provider' => 'turnstile',
'site_key' => 'site-abc',
'secret_key' => 'secret-abc',
'on_login' => false,
'on_public_comments' => false,
]));
$captcha = app(Captcha::class);
expect($captcha->protects(CaptchaForm::Login))->toBeFalse()
->and($captcha->protects(CaptchaForm::Comment))->toBeFalse()
->and($captcha->protects(CaptchaForm::Register))->toBeTrue();
});
test('community is never offered the platform keys, and cannot be made to use them', function () {
config()->set('projectsend.captcha.managed', [
'provider' => 'turnstile',
'site_key' => 'platform-site',
'secret_key' => 'platform-secret',
'score_threshold' => 0.5,
]);
$this->actingAs($this->admin)->get('/system/settings/captcha')->assertInertia(
fn (AssertableInertia $page) => $page->where('managed_keys_available', false)
);
// A hand-crafted PATCH: the field is not in the rules and is never
// read, so it cannot smuggle the platform's credentials into a
// self-hosted install.
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
'key_source' => 'managed',
'provider' => 'recaptcha_v2',
'site_key' => 'own-site',
'secret_key' => 'own-secret',
]));
expect(app(Settings::class)->get(Setting::CaptchaKeySource))->toBe('own')
->and(app(Captcha::class)->active()->siteKey)->toBe('own-site');
});
test('cloud can choose the platform keys, and choosing them leaves its own alone', function () {
config()->set('projectsend.edition', Edition::Cloud);
config()->set('projectsend.captcha.managed', [
'provider' => 'turnstile',
'site_key' => 'platform-site',
'secret_key' => 'platform-secret',
'score_threshold' => 0.5,
]);
CaptchaSettings::for(CaptchaProvider::RecaptchaV2)->fill([
'site_key' => 'tenant-site',
'secret_key' => 'tenant-secret',
])->save();
$this->actingAs($this->admin)->patch('/system/settings/captcha', [
'key_source' => 'managed',
'on_login' => true,
'on_registration' => true,
'on_password_reset' => true,
'on_public_comments' => true,
])->assertRedirect();
expect(app(Captcha::class)->active()->siteKey)->toBe('platform-site')
// Still there, so flipping back needs no re-keying.
->and(CaptchaSettings::for(CaptchaProvider::RecaptchaV2)->site_key)->toBe('tenant-site');
});
test('the test button proves a secret without anybody solving a challenge', function () {
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => false, 'error-codes' => ['invalid-input-response']]),
]);
$this->actingAs($this->admin)
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'secret-abc'])
->assertRedirect()
->assertSessionHas('captcha_test_result', fn (array $result) => $result['ok'] === true);
});
// Cloudflare's always-pass testing secret verifies anything, including
// the probe. Without a branch for it, the clearest possible "your key
// works" reads as an unexpected reply.
test('the test button accepts a secret that verifies the probe outright', function () {
Http::fake(['challenges.cloudflare.com/*' => Http::response(['success' => true])]);
$this->actingAs($this->admin)
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'testing-key'])
->assertSessionHas('captcha_test_result', fn (array $result) => $result['ok'] === true);
});
test('the test button reports a rejected secret as rejected', function () {
Http::fake([
'challenges.cloudflare.com/*' => Http::response(['success' => false, 'error-codes' => ['invalid-input-secret']]),
]);
$this->actingAs($this->admin)
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'wrong'])
->assertSessionHas('captcha_test_result', fn (array $result) => $result['ok'] === false);
});
test('saving clears a stale outage warning', function () {
Http::fake(['challenges.cloudflare.com/*' => Http::failedConnection()]);
CaptchaSettings::for(CaptchaProvider::Turnstile)->fill(['site_key' => 's', 'secret_key' => 'k'])->save();
app(Settings::class)->set(Setting::CaptchaProvider, 'turnstile');
Captcha::forgetDisplayCache();
app(CaptchaVerifier::class)->verify('token', CaptchaForm::Login);
expect(CaptchaVerifier::lastError())->not->toBeNull();
$this->actingAs($this->admin)->patch('/system/settings/captcha', captchaPayload([
'provider' => 'turnstile', 'site_key' => 's', 'secret_key' => 'corrected',
]));
expect(CaptchaVerifier::lastError())->toBeNull();
});