Skip to content

Commit 7dee6e4

Browse files
chore: Add PR's requested changes and Unit tests
- Validation failure when cf-turnstile-response is missing - Login flow behaviour before/after CAPTCHA threshold - Server-side login-attempt lookup behaviour when user exists vs does not exist - Rendering of Turnstile on the thresholded login screen - Auth form submission when Turnstile is expired or not solved
1 parent 5067314 commit 7dee6e4

3 files changed

Lines changed: 250 additions & 2 deletions

File tree

phpunit.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,7 @@
2525
</testsuites>
2626
<php>
2727
<env name="APP_ENV" value="testing"/>
28+
<env name="TEST_USER_EMAIL" value="sebastian@tipit.net"/>
29+
<env name="TEST_USER_PASSWORD" value="1Qaz2wsx!"/>
2830
</php>
2931
</phpunit>

resources/js/login/login.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,6 @@ const PasswordInputForm = ({
183183
<input type="hidden" value={userNameValue} id="username" name="username"/>
184184
<input type="hidden" value={csrfToken} id="_token" name="_token"/>
185185
<input type="hidden" value="password" id="flow" name="flow"/>
186-
<input type="hidden" value={loginAttempts ?? 0} name="login_attempts"/>
187186
{shouldShowCaptcha() && captchaPublicKey &&
188187
<Turnstile
189188
className={styles.turnstile}
@@ -265,7 +264,6 @@ const OTPInputForm = ({
265264
<input type="hidden" value="otp" id="flow" name="flow"/>
266265
<input type="hidden" value={otpCode} id="password" name="password"/>
267266
<input type="hidden" value="email" id="connection" name="connection"/>
268-
<input type="hidden" value={loginAttempts ?? 0} name="login_attempts"/>
269267
{shouldShowCaptcha() && captchaPublicKey &&
270268
<Turnstile
271269
className={styles.turnstile}

tests/UserLoginTurnstileTest.php

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
<?php namespace Tests;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Auth\User;
16+
use Illuminate\Support\Facades\Http;
17+
use Illuminate\Support\Facades\Session;
18+
use LaravelDoctrine\ORM\Facades\EntityManager;
19+
20+
/**
21+
* Class UserLoginTurnstileTest
22+
*
23+
* Covers Cloudflare Turnstile integration in UserController::postLogin():
24+
* - cf-turnstile-response required when login_failed_attempt >= threshold
25+
* - threshold gating (before / at boundary)
26+
* - server-side attempt lookup for existing vs. unknown users
27+
* - login screen emits Turnstile JS config after a failed attempt
28+
* - expired or unsolved token is rejected
29+
*/
30+
final class UserLoginTurnstileTest extends BrowserKitTestCase
31+
{
32+
private const TURNSTILE_SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
33+
private const LOGIN_URL = '/auth/login';
34+
// Matches ServerConfigurationService::DefaultMaxFailedLoginAttempts2ShowCaptcha
35+
private const CAPTCHA_THRESHOLD = 3;
36+
37+
private string $testEmail;
38+
private string $testPassword;
39+
40+
protected function prepareForTests(): void
41+
{
42+
parent::prepareForTests();
43+
$this->testEmail = env('TEST_USER_EMAIL');
44+
$this->testPassword = env('TEST_USER_PASSWORD');
45+
Session::start();
46+
}
47+
48+
// -------------------------------------------------------------------------
49+
// Helpers
50+
// -------------------------------------------------------------------------
51+
52+
private function getTestUser(): User
53+
{
54+
return EntityManager::getRepository(User::class)
55+
->findOneBy(['identifier' => 'sebastian.marcet']);
56+
}
57+
58+
private function setLoginAttempts(User $user, int $attempts): void
59+
{
60+
$user->setLoginFailedAttempt($attempts);
61+
EntityManager::persist($user);
62+
EntityManager::flush();
63+
}
64+
65+
private function postLogin(array $overrides = [])
66+
{
67+
// GET the login page first so the session (and its CSRF token) is established,
68+
// mirroring how a real browser submits the form.
69+
$this->call('GET', self::LOGIN_URL);
70+
71+
return $this->call('POST', self::LOGIN_URL, array_merge([
72+
'username' => $this->testEmail,
73+
'password' => $this->testPassword,
74+
'flow' => 'password',
75+
'_token' => Session::token(),
76+
], $overrides));
77+
}
78+
79+
private function fakeTurnstilePass(): void
80+
{
81+
Http::fake([
82+
self::TURNSTILE_SITEVERIFY_URL => Http::response(['success' => true], 200),
83+
]);
84+
}
85+
86+
private function fakeTurnstileFail(): void
87+
{
88+
Http::fake([
89+
self::TURNSTILE_SITEVERIFY_URL => Http::response(
90+
['success' => false, 'error-codes' => ['timeout-or-duplicate']],
91+
200
92+
),
93+
]);
94+
}
95+
96+
/**
97+
* BrowserKitTesting's assertSessionHasErrors/assertSessionMissing target
98+
* $app['session.store'], which is a fresh Store singleton never populated by
99+
* the request's StartSession middleware ($app['session']->driver()). Use the
100+
* live session driver instead.
101+
*/
102+
private function sessionHasValidationError(string $field): bool
103+
{
104+
$errors = $this->app['session']->driver()->get('errors');
105+
return $errors !== null && $errors->has($field);
106+
}
107+
108+
// -------------------------------------------------------------------------
109+
// 1. Validation failure when cf-turnstile-response is missing
110+
// -------------------------------------------------------------------------
111+
112+
public function testMissingTurnstileResponseFailsValidationWhenAtThreshold(): void
113+
{
114+
$user = $this->getTestUser();
115+
$this->setLoginAttempts($user, self::CAPTCHA_THRESHOLD);
116+
117+
$this->postLogin(); // no cf-turnstile-response
118+
119+
$this->assertTrue(
120+
$this->sessionHasValidationError('cf-turnstile-response'),
121+
'Expected a validation error for cf-turnstile-response when user is at threshold'
122+
);
123+
}
124+
125+
// -------------------------------------------------------------------------
126+
// 2. Login flow behaviour before / after CAPTCHA threshold
127+
// -------------------------------------------------------------------------
128+
129+
public function testLoginBelowThresholdDoesNotRequireTurnstile(): void
130+
{
131+
$user = $this->getTestUser();
132+
$this->setLoginAttempts($user, self::CAPTCHA_THRESHOLD - 1);
133+
134+
$this->postLogin(); // correct credentials, no captcha token
135+
136+
$this->assertFalse(
137+
$this->sessionHasValidationError('cf-turnstile-response'),
138+
'Turnstile must not be required when login attempts are below threshold'
139+
);
140+
}
141+
142+
public function testLoginAtThresholdWithValidTokenPassesValidation(): void
143+
{
144+
$user = $this->getTestUser();
145+
$this->setLoginAttempts($user, self::CAPTCHA_THRESHOLD);
146+
147+
$this->fakeTurnstilePass();
148+
149+
$this->postLogin(['cf-turnstile-response' => 'dummy-token-accepted-by-mock']);
150+
151+
$this->assertFalse(
152+
$this->sessionHasValidationError('cf-turnstile-response'),
153+
'A valid Turnstile token must clear the captcha validation rule'
154+
);
155+
}
156+
157+
// -------------------------------------------------------------------------
158+
// 3. Server-side login-attempt lookup: user exists vs. does not exist
159+
// -------------------------------------------------------------------------
160+
161+
public function testLoginAttemptsLoadedFromExistingUserRecord(): void
162+
{
163+
$user = $this->getTestUser();
164+
$this->setLoginAttempts($user, self::CAPTCHA_THRESHOLD);
165+
166+
// Omit cf-turnstile-response. If the controller had NOT read the DB value it
167+
// would see login_attempts = 0 and skip the captcha rule. The error proves
168+
// the persisted attempt count was used.
169+
$this->postLogin();
170+
171+
$this->assertTrue(
172+
$this->sessionHasValidationError('cf-turnstile-response'),
173+
'Expected captcha required error, which proves login_failed_attempt was read from DB'
174+
);
175+
}
176+
177+
public function testLoginAttemptsDefaultToZeroForUnknownUsername(): void
178+
{
179+
// No user with this email → auth_service->getUserByUsername() returns null
180+
// login_attempts stays 0 → captcha rule is never added to the validator
181+
$this->postLogin([
182+
'username' => 'nobody@doesnotexist.example',
183+
'password' => 'irrelevant',
184+
]);
185+
186+
$this->assertFalse(
187+
$this->sessionHasValidationError('cf-turnstile-response'),
188+
'Turnstile must not be required when the username does not exist in the DB'
189+
);
190+
}
191+
192+
// -------------------------------------------------------------------------
193+
// 4. Rendering of Turnstile on the thresholded login screen
194+
// -------------------------------------------------------------------------
195+
196+
public function testLoginScreenIncludesTurnstileConfigWhenAboveThreshold(): void
197+
{
198+
$user = $this->getTestUser();
199+
// Place user one below threshold; the wrong-password attempt crosses it.
200+
$this->setLoginAttempts($user, self::CAPTCHA_THRESHOLD - 1);
201+
202+
$this->postLogin(['password' => 'wrong-password']);
203+
204+
// errorLogin() flashes max_login_attempts_2_show_captcha into the session;
205+
// following the redirect renders login.blade.php which emits those values.
206+
$html = $this->call('GET', self::LOGIN_URL)->getContent();
207+
208+
// captchaPublicKey is always rendered (login.blade.php, not conditional)
209+
$this->assertStringContainsString('captchaPublicKey', $html);
210+
211+
// maxLoginAttempts2ShowCaptcha is emitted when the session key is set
212+
$this->assertStringContainsString('maxLoginAttempts2ShowCaptcha', $html);
213+
}
214+
215+
// -------------------------------------------------------------------------
216+
// 5. Auth form submission when Turnstile is expired or not solved
217+
// -------------------------------------------------------------------------
218+
219+
public function testExpiredTurnstileTokenFailsValidation(): void
220+
{
221+
$user = $this->getTestUser();
222+
$this->setLoginAttempts($user, self::CAPTCHA_THRESHOLD);
223+
224+
// Cloudflare API returns success=false (expired / already-used token)
225+
$this->fakeTurnstileFail();
226+
227+
$this->postLogin(['cf-turnstile-response' => 'expired-or-invalid-token']);
228+
229+
$this->assertTrue(
230+
$this->sessionHasValidationError('cf-turnstile-response'),
231+
'An expired or invalid Turnstile token must produce a validation error'
232+
);
233+
}
234+
235+
public function testUnsolvedCaptchaEmptyTokenFailsValidation(): void
236+
{
237+
$user = $this->getTestUser();
238+
$this->setLoginAttempts($user, self::CAPTCHA_THRESHOLD);
239+
240+
// Empty string triggers the 'required' rule before any Cloudflare call
241+
$this->postLogin(['cf-turnstile-response' => '']);
242+
243+
$this->assertTrue(
244+
$this->sessionHasValidationError('cf-turnstile-response'),
245+
'An empty Turnstile response must be rejected by the required rule'
246+
);
247+
}
248+
}

0 commit comments

Comments
 (0)