MDL-68183 auth: Fix the performance of forgotten password user search
[moodle.git] / login / tests / lib_test.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Unit tests for login lib.
19  *
20  * @package    core
21  * @copyright  2017 Juan Leyva
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 global $CFG;
28 require_once($CFG->dirroot . '/login/lib.php');
30 /**
31  * Login lib testcase.
32  *
33  * @package    core
34  * @copyright  2017 Juan Leyva
35  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36  */
37 class core_login_lib_testcase extends advanced_testcase {
39     public function test_core_login_process_password_reset_one_time_without_username_protection() {
40         global $CFG;
42         $this->resetAfterTest();
43         $CFG->protectusernames = 0;
44         $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
46         $sink = $this->redirectEmails();
48         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
49         $this->assertSame('emailresetconfirmsent', $status);
50         $emails = $sink->get_messages();
51         $this->assertCount(1, $emails);
52         $email = reset($emails);
53         $this->assertSame($user->email, $email->to);
54         $this->assertNotEmpty($email->header);
55         $this->assertNotEmpty($email->body);
56         $this->assertRegExp('/A password reset was requested for your account/', quoted_printable_decode($email->body));
57         $sink->clear();
58     }
60     public function test_core_login_process_password_reset_two_consecutive_times_without_username_protection() {
61         global $CFG;
63         $this->resetAfterTest();
64         $CFG->protectusernames = 0;
65         $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
67         $sink = $this->redirectEmails();
69         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
70         $this->assertSame('emailresetconfirmsent', $status);
71         // Request for a second time.
72         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
73         $this->assertSame('emailresetconfirmsent', $status);
74         $emails = $sink->get_messages();
75         $this->assertCount(2, $emails); // Two emails sent (one per each request).
76         $email = array_pop($emails);
77         $this->assertSame($user->email, $email->to);
78         $this->assertNotEmpty($email->header);
79         $this->assertNotEmpty($email->body);
80         $this->assertRegExp('/A password reset was requested for your account/', quoted_printable_decode($email->body));
81         $sink->clear();
82     }
84     public function test_core_login_process_password_reset_three_consecutive_times_without_username_protection() {
85         global $CFG;
87         $this->resetAfterTest();
88         $CFG->protectusernames = 0;
89         $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
91         $sink = $this->redirectEmails();
93         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
94         $this->assertSame('emailresetconfirmsent', $status);
95         // Request for a second time.
96         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
97         $this->assertSame('emailresetconfirmsent', $status);
98         // Third time.
99         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
100         $this->assertSame('emailalreadysent', $status);
101         $emails = $sink->get_messages();
102         $this->assertCount(2, $emails); // Third time email is not sent.
103     }
105     public function test_core_login_process_password_reset_one_time_with_username_protection() {
106         global $CFG;
108         $this->resetAfterTest();
109         $CFG->protectusernames = 1;
110         $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
112         $sink = $this->redirectEmails();
114         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
115         $this->assertSame('emailpasswordconfirmmaybesent', $status);   // Generic message not giving clues.
116         $emails = $sink->get_messages();
117         $this->assertCount(1, $emails);
118         $email = reset($emails);
119         $this->assertSame($user->email, $email->to);
120         $this->assertNotEmpty($email->header);
121         $this->assertNotEmpty($email->body);
122         $this->assertRegExp('/A password reset was requested for your account/', quoted_printable_decode($email->body));
123         $sink->clear();
124     }
126     public function test_core_login_process_password_reset_with_preexisting_expired_request_without_username_protection() {
127         global $CFG, $DB;
129         $this->resetAfterTest();
130         $CFG->protectusernames = 0;
131         $user = $this->getDataGenerator()->create_user(array('auth' => 'manual'));
133         $sink = $this->redirectEmails();
135         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
136         $this->assertSame('emailresetconfirmsent', $status);
137         // Request again.
138         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
139         $this->assertSame('emailresetconfirmsent', $status);
141         $resetrequests = $DB->get_records('user_password_resets');
142         $request = reset($resetrequests);
143         $request->timerequested = time() - YEARSECS;
144         $DB->update_record('user_password_resets', $request);
146         // Request again - third time - but it shuld be expired so we should get an email.
147         list($status, $notice, $url) = core_login_process_password_reset($user->username, null);
148         $this->assertSame('emailresetconfirmsent', $status);
149         $emails = $sink->get_messages();
150         $this->assertCount(3, $emails); // Normal process, the previous request was deleted.
151         $email = reset($emails);
152         $this->assertSame($user->email, $email->to);
153         $this->assertNotEmpty($email->header);
154         $this->assertNotEmpty($email->body);
155         $this->assertRegExp('/A password reset was requested for your account/', quoted_printable_decode($email->body));
156         $sink->clear();
157     }
159     public function test_core_login_process_password_reset_disabled_auth() {
160         $this->resetAfterTest();
161         $user = $this->getDataGenerator()->create_user(array('auth' => 'oauth2'));
163         $sink = $this->redirectEmails();
165         core_login_process_password_reset($user->username, null);
166         $emails = $sink->get_messages();
167         $this->assertCount(1, $emails);
168         $email = reset($emails);
169         $this->assertSame($user->email, $email->to);
170         $this->assertNotEmpty($email->header);
171         $this->assertNotEmpty($email->body);
172         $this->assertRegExp('/Unfortunately your account on this site is disabled/', quoted_printable_decode($email->body));
173         $sink->clear();
174     }
176     public function test_core_login_process_password_reset_auth_not_supporting_email_reset() {
177         global $CFG;
179         $this->resetAfterTest();
180         $CFG->auth = $CFG->auth . ',mnet';
181         $user = $this->getDataGenerator()->create_user(array('auth' => 'mnet'));
183         $sink = $this->redirectEmails();
185         core_login_process_password_reset($user->username, null);
186         $emails = $sink->get_messages();
187         $this->assertCount(1, $emails);
188         $email = reset($emails);
189         $this->assertSame($user->email, $email->to);
190         $this->assertNotEmpty($email->header);
191         $this->assertNotEmpty($email->body);
192         $this->assertRegExp('/Unfortunately passwords cannot be reset on this site/', quoted_printable_decode($email->body));
193         $sink->clear();
194     }
196     public function test_core_login_process_password_reset_missing_parameters() {
197         $this->expectException('moodle_exception');
198         $this->expectExceptionMessage(get_string('cannotmailconfirm', 'error'));
199         core_login_process_password_reset(null, null);
200     }
202     public function test_core_login_process_password_reset_invalid_username_with_username_protection() {
203         global $CFG;
204         $this->resetAfterTest();
205         $CFG->protectusernames = 1;
206         list($status, $notice, $url) = core_login_process_password_reset('72347234nasdfasdf/Ds', null);
207         $this->assertEquals('emailpasswordconfirmmaybesent', $status);
208     }
210     public function test_core_login_process_password_reset_invalid_username_without_username_protection() {
211         global $CFG;
212         $this->resetAfterTest();
213         $CFG->protectusernames = 0;
214         list($status, $notice, $url) = core_login_process_password_reset('72347234nasdfasdf/Ds', null);
215         $this->assertEquals('emailpasswordconfirmnotsent', $status);
216     }
218     public function test_core_login_process_password_reset_invalid_email_without_username_protection() {
219         global $CFG;
220         $this->resetAfterTest();
221         $CFG->protectusernames = 0;
222         list($status, $notice, $url) = core_login_process_password_reset(null, 'fakeemail@nofd.zdy');
223         $this->assertEquals('emailpasswordconfirmnotsent', $status);
224     }
226     /**
227      * Data provider for \core_login_lib_testcase::test_core_login_validate_forgot_password_data().
228      */
229     public function forgot_password_data_provider() {
230         return [
231             'Both username and password supplied' => [
232                 [
233                     'username' => 's1',
234                     'email' => 's1@example.com'
235                 ],
236                 [
237                     'username' => get_string('usernameoremail'),
238                     'email' => get_string('usernameoremail'),
239                 ]
240             ],
241             'Valid username' => [
242                 ['username' => 's1']
243             ],
244             'Valid username, different case' => [
245                 ['username' => 'S1']
246             ],
247             'Valid username, different case, username protection off' => [
248                 ['username' => 'S1'],
249                 [],
250                 ['protectusernames' => 0]
251             ],
252             'Non-existent username' => [
253                 ['username' => 's2'],
254             ],
255             'Non-existing username, username protection off' => [
256                 ['username' => 's2'],
257                 ['username' => get_string('usernamenotfound')],
258                 ['protectusernames' => 0]
259             ],
260             'Valid username, unconfirmed username' => [
261                 ['username' => 's1'],
262                 ['email' => get_string('confirmednot')],
263                 ['confirmed' => 0]
264             ],
265             'Invalid email' => [
266                 ['email' => 's1-example.com'],
267                 ['email' => get_string('invalidemail')]
268             ],
269             'Multiple accounts with the same email' => [
270                 ['email' => 's1@example.com'],
271                 ['email' => get_string('forgottenduplicate')],
272                 ['allowaccountssameemail' => 1]
273             ],
274             'Multiple accounts with the same email but with different case' => [
275                 ['email' => 'S1@EXAMPLE.COM'],
276                 ['email' => get_string('forgottenduplicate')],
277                 ['allowaccountssameemail' => 1]
278             ],
279             'Non-existent email, username protection on' => [
280                 ['email' => 's2@example.com']
281             ],
282             'Non-existent email, username protection off' => [
283                 ['email' => 's2@example.com'],
284                 ['email' => get_string('emailnotfound')],
285                 ['protectusernames' => 0]
286             ],
287             'Valid email' => [
288                 ['email' => 's1@example.com']
289             ],
290             'Valid email, different case' => [
291                 ['email' => 'S1@EXAMPLE.COM']
292             ],
293             'Valid email, unconfirmed user' => [
294                 ['email' => 's1@example.com'],
295                 ['email' => get_string('confirmednot')],
296                 ['confirmed' => 0]
297             ],
298         ];
299     }
301     /**
302      * Test for core_login_validate_forgot_password_data().
303      *
304      * @dataProvider forgot_password_data_provider
305      * @param array $data Key-value array containing username and email data.
306      * @param array $errors Key-value array containing error messages for the username and email fields.
307      * @param array $options Options for $CFG->protectusernames, $CFG->allowaccountssameemail and $user->confirmed.
308      */
309     public function test_core_login_validate_forgot_password_data($data, $errors = [], $options = []) {
310         $this->resetAfterTest();
312         // Set config settings we need for our environment.
313         $protectusernames = $options['protectusernames'] ?? 1;
314         set_config('protectusernames', $protectusernames);
316         $allowaccountssameemail = $options['allowaccountssameemail'] ?? 0;
317         set_config('allowaccountssameemail', $allowaccountssameemail);
319         // Generate the user data.
320         $generator = $this->getDataGenerator();
321         $userdata = [
322             'username' => 's1',
323             'email' => 's1@example.com',
324             'confirmed' => $options['confirmed'] ?? 1
325         ];
326         $generator->create_user($userdata);
328         if ($allowaccountssameemail) {
329             // Create another user with the same email address.
330             $generator->create_user(['email' => 's1@example.com']);
331         }
333         // Validate the data.
334         $validationerrors = core_login_validate_forgot_password_data($data);
336         // Check validation errors for the username field.
337         if (isset($errors['username'])) {
338             // If we expect and error for the username field, confirm that it's set.
339             $this->assertArrayHasKey('username', $validationerrors);
340             // And the actual validation error is equal to the expected validation error.
341             $this->assertEquals($errors['username'], $validationerrors['username']);
342         } else {
343             // If we don't expect that there's a validation for the username field, confirm that it's not set.
344             $this->assertArrayNotHasKey('username', $validationerrors);
345         }
347         // Check validation errors for the email field.
348         if (isset($errors['email'])) {
349             // If we expect and error for the email field, confirm that it's set.
350             $this->assertArrayHasKey('email', $validationerrors);
351             // And the actual validation error is equal to the expected validation error.
352             $this->assertEquals($errors['email'], $validationerrors['email']);
353         } else {
354             // If we don't expect that there's a validation for the email field, confirm that it's not set.
355             $this->assertArrayNotHasKey('email', $validationerrors);
356         }
357     }
359     /**
360      * Test searching for the user record by matching the provided email address when resetting password.
361      *
362      * Email addresses should be handled as case-insensitive but accent sensitive.
363      */
364     public function test_core_login_process_password_reset_email_sensitivity() {
365         global $CFG;
366         require_once($CFG->libdir.'/phpmailer/moodle_phpmailer.php');
368         $this->resetAfterTest();
369         $sink = $this->redirectEmails();
370         $CFG->protectusernames = 0;
372         // In this test, we need to mock sending emails on non-ASCII email addresses. However, such email addresses do
373         // not pass the default `validate_email()` and Moodle does not yet provide a CFG switch to allow such emails.
374         // So we inject our own validation method here and revert it back once we are done. This custom validator method
375         // is identical to the default 'php' validator with the only difference: it has the FILTER_FLAG_EMAIL_UNICODE
376         // set so that it allows to use non-ASCII characters in email addresses.
377         $defaultvalidator = moodle_phpmailer::$validator;
378         moodle_phpmailer::$validator = function($address) {
379             return (bool) filter_var($address, FILTER_VALIDATE_EMAIL, FILTER_FLAG_EMAIL_UNICODE);
380         };
382         // Emails are treated as case-insensitive when searching for the matching user account.
383         $u1 = $this->getDataGenerator()->create_user(['email' => 'priliszlutouckykunupeldabelskeody@example.com']);
385         list($status, $notice, $url) = core_login_process_password_reset(null, 'PrIlIsZlUtOuCkYKuNupELdAbElSkEoDy@eXaMpLe.CoM');
387         $this->assertSame('emailresetconfirmsent', $status);
388         $emails = $sink->get_messages();
389         $this->assertCount(1, $emails);
390         $email = reset($emails);
391         $this->assertSame($u1->email, $email->to);
392         $sink->clear();
394         // There may exist two users with same emails.
395         $u2 = $this->getDataGenerator()->create_user(['email' => 'PRILISZLUTOUCKYKUNUPELDABELSKEODY@example.com']);
397         list($status, $notice, $url) = core_login_process_password_reset(null, 'PrIlIsZlUtOuCkYKuNupELdAbElSkEoDy@eXaMpLe.CoM');
399         $this->assertSame('emailresetconfirmsent', $status);
400         $emails = $sink->get_messages();
401         $this->assertCount(1, $emails);
402         $email = reset($emails);
403         $this->assertSame(core_text::strtolower($u2->email), core_text::strtolower($email->to));
404         $sink->clear();
406         // However, emails are accent sensitive - note this is the u1's email with a single character a -> á changed.
407         list($status, $notice, $url) = core_login_process_password_reset(null, 'priliszlutouckykunupeldábelskeody@example.com');
409         $this->assertSame('emailpasswordconfirmnotsent', $status);
410         $emails = $sink->get_messages();
411         $this->assertCount(0, $emails);
412         $sink->clear();
414         $u3 = $this->getDataGenerator()->create_user(['email' => 'PřílišŽluťoučkýKůňÚpělĎálebskéÓdy@example.com']);
416         list($status, $notice, $url) = core_login_process_password_reset(null, 'pŘÍLIŠžLuŤOuČkÝkŮŇúPĚLďÁLEBSKÉóDY@eXaMpLe.CoM');
418         $this->assertSame('emailresetconfirmsent', $status);
419         $emails = $sink->get_messages();
420         $this->assertCount(1, $emails);
421         $email = reset($emails);
422         $this->assertSame($u3->email, $email->to);
423         $sink->clear();
425         // Restore the original email address validator.
426         moodle_phpmailer::$validator = $defaultvalidator;
427     }