Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
287 / 287
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
phpbb_captcha_turnstile_test
100.00% covered (success)
100.00%
287 / 287
100.00% covered (success)
100.00%
21 / 21
21
100.00% covered (success)
100.00%
1 / 1
 getDataSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setUp
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 test_is_available
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 test_attempt_count_increase
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
 test_reset
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 test_get_hidden_fields
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 test_not_available
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 test_get_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 test_set_Name
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 test_validate_without_response
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 test_validate_with_response_success
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 test_validate_with_guzzle_exception
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 test_validate_previous_solve
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 test_has_config
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 test_get_client
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 test_validate_with_response_failure
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 test_get_template
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 test_get_demo_template
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 test_acp_page_display
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
1
 test_acp_page_submit_without_form
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 test_acp_page_submit_valid
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
ModuleMock
n/a
0 / 0
n/a
0 / 0
0
n/a
0 / 0
1<?php
2/**
3 *
4 * This file is part of the phpBB Forum Software package.
5 *
6 * @copyright (c) phpBB Limited <https://www.phpbb.com>
7 * @license GNU General Public License, version 2 (GPL-2.0)
8 *
9 * For full copyright and license information, please see
10 * the docs/CREDITS.txt file.
11 *
12 */
13
14use phpbb\captcha\plugins\confirm_type;
15use phpbb\captcha\plugins\turnstile;
16use phpbb\config\config;
17use phpbb\db\driver\driver_interface;
18use phpbb\form\form_helper;
19use phpbb\language\language;
20use phpbb\log\log_interface;
21use phpbb\request\request;
22use phpbb\request\request_interface;
23use phpbb\template\template;
24use phpbb\user;
25use GuzzleHttp\Client;
26use GuzzleHttp\Psr7\Response;
27use GuzzleHttp\Psr7\Utils;
28
29require_once __DIR__ . '/../../phpBB/includes/functions_acp.php';
30
31class phpbb_captcha_turnstile_test extends \phpbb_database_test_case
32{
33    /** @var turnstile */
34    protected $turnstile;
35
36    /** @var PHPUnit\Framework\MockObject\MockObject */
37    protected $config;
38
39    /** @var PHPUnit\Framework\MockObject\MockObject */
40    protected $db;
41
42    /** @var PHPUnit\Framework\MockObject\MockObject */
43    protected $language;
44
45    /** @var PHPUnit\Framework\MockObject\MockObject */
46    protected $log;
47
48    /** @var PHPUnit\Framework\MockObject\MockObject */
49    protected $request;
50
51    /** @var PHPUnit\Framework\MockObject\MockObject */
52    protected $template;
53
54    /** @var PHPUnit\Framework\MockObject\MockObject */
55    protected $user;
56
57    public function getDataSet()
58    {
59        return $this->createXMLDataSet(__DIR__ . '/../fixtures/empty.xml');
60    }
61
62    protected function setUp(): void
63    {
64        // Mock the dependencies
65        $this->config = $this->createMock(config::class);
66        $this->db = $this->new_dbal();
67        $this->language = $this->createMock(language::class);
68        $this->log = $this->createMock(log_interface::class);
69        $this->request = $this->createMock(request::class);
70        $this->template = $this->createMock(template::class);
71        $this->user = $this->createMock(user::class);
72
73        $this->language->method('lang')->willReturnArgument(0);
74
75        // Instantiate the turnstile class with the mocked dependencies
76        $this->turnstile = new turnstile(
77            $this->config,
78            $this->db,
79            $this->language,
80            $this->log,
81            $this->request,
82            $this->template,
83            $this->user
84        );
85    }
86
87    public function test_is_available(): void
88    {
89        // Test when both sitekey and secret are present
90        $this->config->method('offsetGet')->willReturnMap([
91            ['captcha_turnstile_sitekey', 'sitekey_value'],
92            ['captcha_turnstile_secret', 'secret_value'],
93        ]);
94
95        $this->request->method('variable')->willReturnMap([
96            ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'],
97            ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code']
98        ]);
99
100        $this->assertTrue($this->turnstile->is_available());
101
102        $this->assertEquals(0, $this->turnstile->get_attempt_count());
103    }
104
105    public function test_attempt_count_increase(): void
106    {
107        // Test when both sitekey and secret are present
108        $this->config->method('offsetGet')->willReturnMap([
109            ['captcha_turnstile_sitekey', 'sitekey_value'],
110            ['captcha_turnstile_secret', 'secret_value'],
111        ]);
112
113        $this->request->method('variable')->willReturnMap([
114            ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'],
115            ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code']
116        ]);
117
118        $this->turnstile->init(confirm_type::REGISTRATION);
119        $this->assertFalse($this->turnstile->validate());
120
121        $confirm_id_reflection = new \ReflectionProperty($this->turnstile, 'confirm_id');
122        $confirm_id = $confirm_id_reflection->getValue($this->turnstile);
123
124        $this->request = $this->createMock(request::class);
125        $this->request->method('variable')->willReturnMap([
126            ['confirm_id', '', false, request_interface::REQUEST, $confirm_id],
127            ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code']
128        ]);
129
130        $this->turnstile = new turnstile(
131            $this->config,
132            $this->db,
133            $this->language,
134            $this->log,
135            $this->request,
136            $this->template,
137            $this->user
138        );
139
140        $this->turnstile->init(confirm_type::REGISTRATION);
141        $this->assertEquals(1, $this->turnstile->get_attempt_count());
142
143        // Run some garbage collection
144        $this->turnstile->garbage_collect(confirm_type::REGISTRATION);
145
146        // Start again at 0 after garbage collection
147        $this->turnstile->init(confirm_type::REGISTRATION);
148        $this->assertEquals(0, $this->turnstile->get_attempt_count());
149    }
150
151    public function test_reset(): void
152    {
153        // Test when both sitekey and secret are present
154        $this->config->method('offsetGet')->willReturnMap([
155            ['captcha_turnstile_sitekey', 'sitekey_value'],
156            ['captcha_turnstile_secret', 'secret_value'],
157        ]);
158
159        $this->request->method('variable')->willReturnMap([
160            ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'],
161            ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code']
162        ]);
163
164        $this->turnstile->init(confirm_type::REGISTRATION);
165        $this->assertFalse($this->turnstile->validate());
166        $this->turnstile->reset();
167
168        $confirm_id_reflection = new \ReflectionProperty($this->turnstile, 'confirm_id');
169        $confirm_id = $confirm_id_reflection->getValue($this->turnstile);
170
171        $this->request = $this->createMock(request::class);
172        $this->request->method('variable')->willReturnMap([
173            ['confirm_id', '', false, request_interface::REQUEST, $confirm_id],
174            ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code']
175        ]);
176
177        $this->turnstile = new turnstile(
178            $this->config,
179            $this->db,
180            $this->language,
181            $this->log,
182            $this->request,
183            $this->template,
184            $this->user
185        );
186
187        $this->turnstile->init(confirm_type::REGISTRATION);
188        // Should be zero attempts since we reset the captcha
189        $this->assertEquals(0,  $this->turnstile->get_attempt_count());
190    }
191
192    public function test_get_hidden_fields(): void
193    {
194        // Test when both sitekey and secret are present
195        $this->config->method('offsetGet')->willReturnMap([
196            ['captcha_turnstile_sitekey', 'sitekey_value'],
197            ['captcha_turnstile_secret', 'secret_value'],
198        ]);
199
200        $this->request->method('variable')->willReturnMap([
201            ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'],
202            ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code']
203        ]);
204
205        $this->turnstile->init(confirm_type::REGISTRATION);
206        $this->assertFalse($this->turnstile->validate());
207        $this->turnstile->reset();
208
209        $confirm_id_reflection = new \ReflectionProperty($this->turnstile, 'confirm_id');
210        $confirm_id = $confirm_id_reflection->getValue($this->turnstile);
211
212        $this->assertEquals(
213            [
214                'confirm_id'        => $confirm_id,
215                'confirm_code'        => '',
216            ],
217            $this->turnstile->get_hidden_fields(),
218        );
219        $this->assertEquals('CONFIRM_CODE_WRONG', $this->turnstile->get_error());
220    }
221
222    public function test_not_available(): void
223    {
224        $this->request->method('variable')->willReturnMap([
225            ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'],
226            ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code']
227        ]);
228
229        // Test when sitekey or secret is missing
230        $this->config->method('offsetGet')->willReturnMap([
231            ['captcha_turnstile_sitekey', ''],
232            ['captcha_turnstile_secret', 'secret_value'],
233        ]);
234
235        $this->assertFalse($this->turnstile->is_available());
236    }
237
238    public function test_get_name(): void
239    {
240        $this->assertEquals('CAPTCHA_TURNSTILE', $this->turnstile->get_name());
241    }
242
243    public function test_set_Name(): void
244    {
245        $this->turnstile->set_name('custom_service');
246        $service_name_property = new \ReflectionProperty($this->turnstile, 'service_name');
247        $this->assertEquals('custom_service', $service_name_property->getValue($this->turnstile));
248    }
249
250    public function test_validate_without_response(): void
251    {
252        // Test when there is no Turnstile response
253        $this->request->method('variable')->with('cf-turnstile-response')->willReturn('');
254
255        $this->assertFalse($this->turnstile->validate());
256    }
257
258    public function test_validate_with_response_success(): void
259    {
260        // Mock the request and response from the Turnstile API
261        $this->request->method('variable')->with('cf-turnstile-response')->willReturn('valid_response');
262        $this->request->method('header')->with('CF-Connecting-IP')->willReturn('127.0.0.1');
263
264        // Mock the GuzzleHttp client and response
265        $client_mock = $this->createMock(Client::class);
266        $response_mock = $this->createMock(Response::class);
267
268        $client_mock->method('request')->willReturn($response_mock);
269        $response_mock->method('getBody')->willReturn(Utils::streamFor(json_encode(['success' => true])));
270
271        // Mock config values for secret
272        $this->config->method('offsetGet')->willReturn('secret_value');
273
274        // Use reflection to inject the mocked client into the turnstile class
275        $reflection = new \ReflectionClass($this->turnstile);
276        $client_property = $reflection->getProperty('client');
277        $client_property->setValue($this->turnstile, $client_mock);
278
279        // Validate that the CAPTCHA was solved successfully
280        $this->assertTrue($this->turnstile->validate());
281    }
282
283    public function test_validate_with_guzzle_exception(): void
284    {
285        // Mock the request and response from the Turnstile API
286        $this->request->method('variable')->with('cf-turnstile-response')->willReturn('valid_response');
287        $this->request->method('header')->with('CF-Connecting-IP')->willReturn('127.0.0.1');
288
289        // Mock the GuzzleHttp client and response
290        $client_mock = $this->createMock(Client::class);
291
292        $request_mock = $this->createMock(\GuzzleHttp\Psr7\Request::class);
293        $exception = new \GuzzleHttp\Exception\ConnectException('Failed at connecting', $request_mock);
294        $client_mock->method('request')->willThrowException($exception);
295
296        // Mock config values for secret
297        $this->config->method('offsetGet')->willReturn('secret_value');
298
299        // Use reflection to inject the mocked client into the turnstile class
300        $reflection = new \ReflectionClass($this->turnstile);
301        $client_property = $reflection->getProperty('client');
302        $client_property->setValue($this->turnstile, $client_mock);
303
304        // Validatation fails due to guzzle exception
305        $this->assertFalse($this->turnstile->validate());
306    }
307
308    public function test_validate_previous_solve(): void
309    {
310        // Use reflection to inject the mocked client into the turnstile class
311        $reflection = new \ReflectionClass($this->turnstile);
312        $confirm_id = $reflection->getProperty('confirm_id');
313        $confirm_id->setValue($this->turnstile, 'confirm_id');
314        $code_property = $reflection->getProperty('code');
315        $code_property->setValue($this->turnstile, 'test_code');
316        $confirm_code_property = $reflection->getProperty('confirm_code');
317        $confirm_code_property->setValue($this->turnstile, 'test_code');
318
319        // Validate that the CAPTCHA was solved successfully
320        $this->assertTrue($this->turnstile->validate());
321        $this->assertTrue($this->turnstile->is_solved());
322    }
323
324    public function test_has_config(): void
325    {
326        $this->assertTrue($this->turnstile->has_config());
327    }
328
329    public function test_get_client(): void
330    {
331        $turnstile_reflection = new \ReflectionClass($this->turnstile);
332        $get_client_method = $turnstile_reflection->getMethod('get_client');
333        $client_property = $turnstile_reflection->getProperty('client');
334
335        $this->assertFalse($client_property->isInitialized($this->turnstile));
336        $client = $get_client_method->invoke($this->turnstile);
337        $this->assertNotNull($client);
338        $this->assertInstanceOf(\GuzzleHttp\Client::class, $client);
339        $this->assertTrue($client === $get_client_method->invoke($this->turnstile));
340    }
341
342    public function test_validate_with_response_failure(): void
343    {
344        // Mock the request and response from the Turnstile API
345        $this->request->method('variable')->with('cf-turnstile-response')->willReturn('valid_response');
346        $this->request->method('header')->with('CF-Connecting-IP')->willReturn('127.0.0.1');
347
348        // Mock the GuzzleHttp client and response
349        $client_mock = $this->createMock(Client::class);
350        $response_mock = $this->createMock(Response::class);
351
352        $client_mock->method('request')->willReturn($response_mock);
353        $response_mock->method('getBody')->willReturn(Utils::streamFor(json_encode(['success' => false])));
354
355        // Mock config values for secret
356        $this->config->method('offsetGet')->willReturn('secret_value');
357
358        // Use reflection to inject the mocked client into the turnstile class
359        $reflection = new \ReflectionClass($this->turnstile);
360        $client_property = $reflection->getProperty('client');
361        $client_property->setValue($this->turnstile, $client_mock);
362
363        // Validate that the CAPTCHA was not solved
364        $this->assertFalse($this->turnstile->validate());
365    }
366
367    public function test_get_template(): void
368    {
369        // Mock is_solved to return false
370        $is_solved_property = new \ReflectionProperty($this->turnstile, 'solved');
371        $is_solved_property->setValue($this->turnstile, false);
372
373        // Mock the template assignments
374        $this->config->method('offsetGet')->willReturnMap([
375            ['captcha_turnstile_sitekey', 'sitekey_value'],
376            ['captcha_turnstile_theme', 'light'],
377        ]);
378
379        $this->request->method('variable')->willReturnMap([
380            ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'],
381            ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code']
382        ]);
383
384        $this->template->expects($this->once())->method('assign_vars')->with([
385            'S_TURNSTILE_AVAILABLE' => $this->turnstile->is_available(),
386            'TURNSTILE_SITEKEY' => 'sitekey_value',
387            'TURNSTILE_THEME' => 'light',
388            'U_TURNSTILE_SCRIPT' => 'https://challenges.cloudflare.com/turnstile/v0/api.js',
389            'CONFIRM_TYPE_REGISTRATION' => confirm_type::UNDEFINED->value,
390        ]);
391
392        $this->assertEquals('captcha_turnstile.html', $this->turnstile->get_template());
393
394        $is_solved_property->setValue($this->turnstile, true);
395        $this->assertEquals('', $this->turnstile->get_template());
396    }
397
398    public function test_get_demo_template(): void
399    {
400        // Mock the config assignments
401        $this->config->method('offsetGet')->willReturn('light');
402
403        $this->template->expects($this->once())->method('assign_vars')->with([
404            'TURNSTILE_THEME' => 'light',
405            'U_TURNSTILE_SCRIPT' => 'https://challenges.cloudflare.com/turnstile/v0/api.js',
406        ]);
407
408        $this->assertEquals('captcha_turnstile_acp_demo.html', $this->turnstile->get_demo_template());
409    }
410
411    public function test_acp_page_display(): void
412    {
413        global $phpbb_container, $phpbb_dispatcher, $template;
414
415        $phpbb_container = new phpbb_mock_container_builder();
416        $form_helper = new form_helper($this->config, $this->request, $this->user);
417        $phpbb_container->set('form_helper', $form_helper);
418        $this->user->data['user_id'] = ANONYMOUS;
419        $this->user->data['user_form_salt'] = 'foobar';
420
421        $phpbb_dispatcher = new phpbb_mock_event_dispatcher();
422        $template = $this->template;
423
424        // Mock the template assignments
425        $this->config->method('offsetGet')->willReturnMap([
426            ['captcha_turnstile_sitekey', 'sitekey_value'],
427            ['captcha_turnstile_theme', 'light'],
428        ]);
429
430        $this->request->method('variable')->willReturn('');
431
432        $expected = [
433            1 => [
434                'TURNSTILE_THEME' => 'light',
435                'U_TURNSTILE_SCRIPT' => 'https://challenges.cloudflare.com/turnstile/v0/api.js',
436            ],
437            2 => [
438                'CAPTCHA_PREVIEW'                => 'captcha_turnstile_acp_demo.html',
439                'CAPTCHA_NAME'                    => '',
440                'CAPTCHA_TURNSTILE_THEME'        => 'light',
441                'CAPTCHA_TURNSTILE_THEMES'        => ['light', 'dark', 'auto'],
442                'U_ACTION'                        => 'test_u_action',
443            ],
444        ];
445        $matcher = $this->exactly(count($expected));
446        $this->template
447            ->expects($matcher)
448            ->method('assign_vars')
449            ->willReturnCallback(function ($template_data) use ($matcher, $expected) {
450                $callNr = $matcher->numberOfInvocations();
451                $this->assertEquals($expected[$callNr], $template_data);
452            });
453
454        $module_mock = new ModuleMock();
455
456        $this->turnstile->acp_page('', $module_mock);
457    }
458
459    public function test_acp_page_submit_without_form(): void
460    {
461        global $language, $phpbb_container, $phpbb_dispatcher, $template;
462
463        $language = $this->language;
464        $phpbb_container = new phpbb_mock_container_builder();
465        $form_helper = new form_helper($this->config, $this->request, $this->user);
466        $phpbb_container->set('form_helper', $form_helper);
467        $this->user->data['user_id'] = ANONYMOUS;
468        $this->user->data['user_form_salt'] = 'foobar';
469
470        $phpbb_dispatcher = new phpbb_mock_event_dispatcher();
471        $template = $this->template;
472
473        // Mock the template assignments
474        $this->config->method('offsetGet')->willReturnMap([
475            ['captcha_turnstile_sitekey', 'sitekey_value'],
476            ['captcha_turnstile_theme', 'light'],
477        ]);
478
479        $this->request->method('is_set_post')->willReturnMap([
480            ['creation_time', ''],
481            ['submit', true]
482        ]);
483
484        $this->setExpectedTriggerError(E_USER_NOTICE, 'FORM_INVALID');
485
486        $module_mock = new ModuleMock();
487
488        $this->turnstile->acp_page('', $module_mock);
489    }
490
491    public function test_acp_page_submit_valid(): void
492    {
493        global $language, $phpbb_container, $phpbb_dispatcher, $template;
494
495        $language = $this->language;
496        $phpbb_container = new phpbb_mock_container_builder();
497        $form_helper = new form_helper($this->config, $this->request, $this->user);
498        $phpbb_container->set('form_helper', $form_helper);
499        $this->user->data['user_id'] = ANONYMOUS;
500        $this->user->data['user_form_salt'] = 'foobar';
501
502        $phpbb_dispatcher = new phpbb_mock_event_dispatcher();
503        $template = $this->template;
504
505        $form_tokens = $form_helper->get_form_tokens('acp_captcha');
506
507        // Mock the template assignments
508        $this->config->method('offsetGet')->willReturnMap([
509            ['captcha_turnstile_sitekey', 'sitekey_value'],
510            ['captcha_turnstile_theme', 'light'],
511        ]);
512        $this->config['form_token_lifetime'] = 3600;
513
514        $this->request->method('is_set_post')->willReturnMap([
515            ['creation_time', true],
516            ['form_token', true],
517            ['submit', true]
518        ]);
519
520        $this->request->method('variable')->willReturnMap([
521            ['creation_time', 0, false, request_interface::REQUEST, $form_tokens['creation_time']],
522            ['form_token', '', false, request_interface::REQUEST, $form_tokens['form_token']],
523            ['captcha_turnstile_sitekey', '', false, request_interface::REQUEST, 'newsitekey'],
524            ['captcha_turnstile_theme', 'light', false, request_interface::REQUEST, 'auto'],
525        ]);
526
527        $this->setExpectedTriggerError(E_USER_NOTICE, 'CONFIG_UPDATED');
528
529        $module_mock = new ModuleMock();
530        sleep(1); // sleep for a second to ensure form token validation succeeds
531
532        $this->turnstile->acp_page('', $module_mock);
533    }
534}
535
536class ModuleMock
537{
538    public string $tpl_name = '';
539    public string $page_title = '';
540    public string $u_action = 'test_u_action';
541}