Skip to content

Commit 3f48186

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 - Rendering of Turnstile on the thresholded login screen - Auth form submission when Turnstile is expired or not solved
1 parent 5067314 commit 3f48186

5 files changed

Lines changed: 230 additions & 9 deletions

File tree

.github/workflows/pull_request_unit_tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ jobs:
6363
env:
6464
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.PAT }}"} }'
6565
- name: 'Run Tests'
66+
env:
67+
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
68+
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
6669
run: |
6770
./update_doctrine.sh
6871
php artisan doctrine:migrations:migrate --no-interaction

.github/workflows/push.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ jobs:
6161
env:
6262
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.PAT }}"} }'
6363
- name: 'Run Tests'
64+
env:
65+
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
66+
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
6467
run: |
6568
./update_doctrine.sh
6669
php artisan doctrine:migrations:migrate --no-interaction

app/Http/Controllers/UserController.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -411,13 +411,7 @@ public function postLogin()
411411
if (isset($data['password']))
412412
$data['password'] = trim($data['password']);
413413

414-
if (isset($data['username'])) {
415-
$user = $this->auth_service->getUserByUsername($data['username']);
416-
if (!is_null($user)) {
417-
$login_attempts = $user->getLoginFailedAttempt();
418-
}
419-
}
420-
414+
$login_attempts = intval(Request::input('login_attempts'));
421415
// Build the validation constraint set.
422416
$rules = [
423417
'username' => 'required|email',

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: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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\Session;
17+
use LaravelDoctrine\ORM\Facades\EntityManager;
18+
use RyanChandler\LaravelCloudflareTurnstile\Facades\Turnstile;
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 LOGIN_URL = '/auth/login';
33+
// Matches ServerConfigurationService::DefaultMaxFailedLoginAttempts2ShowCaptcha
34+
private const CAPTCHA_THRESHOLD = 3;
35+
36+
private string $testEmail;
37+
private string $testPassword;
38+
39+
protected function prepareForTests(): void
40+
{
41+
parent::prepareForTests();
42+
$this->testEmail = env('TEST_USER_EMAIL');
43+
$this->testPassword = env('TEST_USER_PASSWORD');
44+
Session::start();
45+
}
46+
47+
// -------------------------------------------------------------------------
48+
// Helpers
49+
// -------------------------------------------------------------------------
50+
51+
private function getTestUser(): User
52+
{
53+
return EntityManager::getRepository(User::class)
54+
->findOneBy(['identifier' => 'sebastian.marcet']);
55+
}
56+
57+
private function postLogin(array $overrides = [])
58+
{
59+
// GET the login page first so the session (and its CSRF token) is established,
60+
// mirroring how a real browser submits the form.
61+
$this->call('GET', self::LOGIN_URL);
62+
63+
return $this->call('POST', self::LOGIN_URL, array_merge([
64+
'username' => $this->testEmail,
65+
'password' => $this->testPassword,
66+
'flow' => 'password',
67+
'_token' => Session::token(),
68+
], $overrides));
69+
}
70+
71+
private function fakeTurnstilePass(): void
72+
{
73+
Turnstile::fake(); // FakeClient defaults to shouldPass=true → returns SiteverifyResponse::success()
74+
}
75+
76+
private function fakeTurnstileFail(): void
77+
{
78+
Turnstile::fake()->expired(); // FakeClient returns failure(['timeout-or-duplicate'])
79+
}
80+
81+
/**
82+
* BrowserKitTesting's assertSessionHasErrors/assertSessionMissing target
83+
* $app['session.store'], which is a fresh Store singleton never populated by
84+
* the request's StartSession middleware ($app['session']->driver()). Use the
85+
* live session driver instead.
86+
*/
87+
private function sessionHasValidationError(string $field): bool
88+
{
89+
$errors = $this->app['session']->driver()->get('errors');
90+
return $errors !== null && $errors->has($field);
91+
}
92+
93+
// -------------------------------------------------------------------------
94+
// 1. Validation failure when cf-turnstile-response is missing
95+
// -------------------------------------------------------------------------
96+
97+
public function testMissingTurnstileResponseFailsValidationWhenAtThreshold(): void
98+
{
99+
$user = $this->getTestUser();
100+
101+
$this->postLogin([
102+
"login_attempts" => self::CAPTCHA_THRESHOLD,
103+
]); // no cf-turnstile-response
104+
105+
$this->assertTrue(
106+
$this->sessionHasValidationError('cf-turnstile-response'),
107+
'Expected a validation error for cf-turnstile-response when user is at threshold'
108+
);
109+
}
110+
111+
// -------------------------------------------------------------------------
112+
// 2. Login flow behaviour before / after CAPTCHA threshold
113+
// -------------------------------------------------------------------------
114+
115+
public function testLoginBelowThresholdDoesNotRequireTurnstile(): void
116+
{
117+
$user = $this->getTestUser();
118+
119+
$this->postLogin([
120+
'login_attempts' => self::CAPTCHA_THRESHOLD - 1
121+
]); // correct credentials, no captcha token
122+
123+
$this->assertFalse(
124+
$this->sessionHasValidationError('cf-turnstile-response'),
125+
'Turnstile must not be required when login attempts are below threshold'
126+
);
127+
}
128+
129+
public function testLoginAtThresholdWithValidTokenPassesValidation(): void
130+
{
131+
$user = $this->getTestUser();
132+
133+
$this->fakeTurnstilePass();
134+
135+
$this->postLogin([
136+
'cf-turnstile-response' => 'dummy-token-accepted-by-mock',
137+
'login_attempts' => 1
138+
]);
139+
140+
$this->assertFalse(
141+
$this->sessionHasValidationError('cf-turnstile-response'),
142+
'A valid Turnstile token must clear the captcha validation rule'
143+
);
144+
}
145+
146+
public function testLoginAttemptsDefaultToZeroForUnknownUsername(): void
147+
{
148+
// No user with this email → auth_service->getUserByUsername() returns null
149+
// login_attempts stays 0 → captcha rule is never added to the validator
150+
$this->postLogin([
151+
'username' => 'nobody@doesnotexist.example',
152+
'password' => 'irrelevant',
153+
]);
154+
155+
$this->assertFalse(
156+
$this->sessionHasValidationError('cf-turnstile-response'),
157+
'Turnstile must not be required when the username does not exist in the DB'
158+
);
159+
}
160+
161+
// -------------------------------------------------------------------------
162+
// 4. Rendering of Turnstile on the thresholded login screen
163+
// -------------------------------------------------------------------------
164+
165+
public function testLoginScreenIncludesTurnstileConfigWhenAboveThreshold(): void
166+
{
167+
$user = $this->getTestUser();
168+
169+
// Place user one below threshold; the wrong-password attempt crosses it.
170+
$this->postLogin([
171+
'password' => 'wrong-password',
172+
'login_attempts' => self::CAPTCHA_THRESHOLD - 1
173+
]);
174+
175+
// errorLogin() flashes max_login_attempts_2_show_captcha into the session;
176+
// following the redirect renders login.blade.php which emits those values.
177+
$html = $this->call('GET', self::LOGIN_URL)->getContent();
178+
179+
// captchaPublicKey is always rendered (login.blade.php, not conditional)
180+
$this->assertStringContainsString('captchaPublicKey', $html);
181+
182+
// maxLoginAttempts2ShowCaptcha is emitted when the session key is set
183+
$this->assertStringContainsString('maxLoginAttempts2ShowCaptcha', $html);
184+
}
185+
186+
// -------------------------------------------------------------------------
187+
// 5. Auth form submission when Turnstile is expired or not solved
188+
// -------------------------------------------------------------------------
189+
190+
public function testExpiredTurnstileTokenFailsValidation(): void
191+
{
192+
$user = $this->getTestUser();
193+
194+
// Cloudflare API returns success=false (expired / already-used token)
195+
$this->fakeTurnstileFail();
196+
197+
$this->postLogin([
198+
'cf-turnstile-response' => 'expired-or-invalid-token',
199+
'login_attempts' => self::CAPTCHA_THRESHOLD
200+
]);
201+
202+
$this->assertTrue(
203+
$this->sessionHasValidationError('cf-turnstile-response'),
204+
'An expired or invalid Turnstile token must produce a validation error'
205+
);
206+
}
207+
208+
public function testUnsolvedCaptchaEmptyTokenFailsValidation(): void
209+
{
210+
$user = $this->getTestUser();
211+
212+
// Empty string triggers the 'required' rule before any Cloudflare call
213+
$this->postLogin([
214+
'cf-turnstile-response' => '',
215+
'login_attempts' => self::CAPTCHA_THRESHOLD
216+
]);
217+
218+
$this->assertTrue(
219+
$this->sessionHasValidationError('cf-turnstile-response'),
220+
'An empty Turnstile response must be rejected by the required rule'
221+
);
222+
}
223+
}

0 commit comments

Comments
 (0)