MDL-65075 tool_mobile: Allow auto-login keys only for requests from Moodle apps
[moodle.git] / admin / tool / mobile / tests / externallib_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  * Moodle Mobile admin tool external functions tests.
19  *
20  * @package    tool_mobile
21  * @category   external
22  * @copyright  2016 Juan Leyva
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  * @since      Moodle 3.1
25  */
27 defined('MOODLE_INTERNAL') || die();
29 global $CFG;
31 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
32 require_once($CFG->dirroot . '/admin/tool/mobile/tests/fixtures/output/mobile.php');
33 require_once($CFG->dirroot . '/webservice/lib.php');
35 use tool_mobile\external;
36 use tool_mobile\api;
38 /**
39  * Moodle Mobile admin tool external functions tests.
40  *
41  * @package     tool_mobile
42  * @copyright   2016 Juan Leyva
43  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44  * @since       Moodle 3.1
45  */
46 class tool_mobile_external_testcase extends externallib_advanced_testcase {
48     /**
49      * Test get_plugins_supporting_mobile.
50      * This is a very basic test because currently there aren't plugins supporting Mobile in core.
51      */
52     public function test_get_plugins_supporting_mobile() {
53         $result = external::get_plugins_supporting_mobile();
54         $result = external_api::clean_returnvalue(external::get_plugins_supporting_mobile_returns(), $result);
55         $this->assertCount(0, $result['warnings']);
56         $this->assertArrayHasKey('plugins', $result);
57         $this->assertTrue(is_array($result['plugins']));
58     }
60     public function test_get_public_config() {
61         global $CFG, $SITE, $OUTPUT;
63         $this->resetAfterTest(true);
64         $result = external::get_public_config();
65         $result = external_api::clean_returnvalue(external::get_public_config_returns(), $result);
67         // Test default values.
68         $context = context_system::instance();
69         list($authinstructions, $notusedformat) = external_format_text($CFG->auth_instructions, FORMAT_MOODLE, $context->id);
70         list($maintenancemessage, $notusedformat) = external_format_text($CFG->maintenance_message, FORMAT_MOODLE, $context->id);
72         $expected = array(
73             'wwwroot' => $CFG->wwwroot,
74             'httpswwwroot' => $CFG->httpswwwroot,
75             'sitename' => external_format_string($SITE->fullname, $context->id, true),
76             'guestlogin' => $CFG->guestloginbutton,
77             'rememberusername' => $CFG->rememberusername,
78             'authloginviaemail' => $CFG->authloginviaemail,
79             'registerauth' => $CFG->registerauth,
80             'forgottenpasswordurl' => $CFG->forgottenpasswordurl,
81             'authinstructions' => $authinstructions,
82             'authnoneenabled' => (int) is_enabled_auth('none'),
83             'enablewebservices' => $CFG->enablewebservices,
84             'enablemobilewebservice' => $CFG->enablemobilewebservice,
85             'maintenanceenabled' => $CFG->maintenance_enabled,
86             'maintenancemessage' => $maintenancemessage,
87             'typeoflogin' => api::LOGIN_VIA_APP,
88             'mobilecssurl' => '',
89             'tool_mobile_disabledfeatures' => '',
90             'launchurl' => "$CFG->wwwroot/$CFG->admin/tool/mobile/launch.php",
91             'country' => $CFG->country,
92             'agedigitalconsentverification' => \core_auth\digital_consent::is_age_digital_consent_verification_enabled(),
93             'autolang' => $CFG->autolang,
94             'lang' => $CFG->lang,
95             'langmenu' => $CFG->langmenu,
96             'langlist' => $CFG->langlist,
97             'locale' => $CFG->locale,
98             'warnings' => array()
99         );
100         $this->assertEquals($expected, $result);
102         // Change some values.
103         set_config('registerauth', 'email');
104         $authinstructions = 'Something with <b>html tags</b>';
105         set_config('auth_instructions', $authinstructions);
106         set_config('typeoflogin', api::LOGIN_VIA_BROWSER, 'tool_mobile');
107         set_config('logo', 'mock.png', 'core_admin');
108         set_config('logocompact', 'mock.png', 'core_admin');
109         set_config('forgottenpasswordurl', 'mailto:fake@email.zy'); // Test old hack.
110         set_config('agedigitalconsentverification', 1);
111         set_config('autolang', 1);
112         set_config('lang', 'a_b');  // Set invalid lang.
113         set_config('disabledfeatures', 'myoverview', 'tool_mobile');
115         list($authinstructions, $notusedformat) = external_format_text($authinstructions, FORMAT_MOODLE, $context->id);
116         $expected['registerauth'] = 'email';
117         $expected['authinstructions'] = $authinstructions;
118         $expected['typeoflogin'] = api::LOGIN_VIA_BROWSER;
119         $expected['forgottenpasswordurl'] = ''; // Expect empty when it's not an URL.
120         $expected['agedigitalconsentverification'] = true;
121         $expected['supportname'] = $CFG->supportname;
122         $expected['supportemail'] = $CFG->supportemail;
123         $expected['autolang'] = '1';
124         $expected['lang'] = ''; // Expect empty because it was set to an invalid lang.
125         $expected['tool_mobile_disabledfeatures'] = 'myoverview';
127         if ($logourl = $OUTPUT->get_logo_url()) {
128             $expected['logourl'] = $logourl->out(false);
129         }
130         if ($compactlogourl = $OUTPUT->get_compact_logo_url()) {
131             $expected['compactlogourl'] = $compactlogourl->out(false);
132         }
134         $result = external::get_public_config();
135         $result = external_api::clean_returnvalue(external::get_public_config_returns(), $result);
136         $this->assertEquals($expected, $result);
137     }
139     /**
140      * Test get_config
141      */
142     public function test_get_config() {
143         global $CFG, $SITE;
144         require_once($CFG->dirroot . '/course/format/lib.php');
146         $this->resetAfterTest(true);
148         $mysitepolicy = 'http://mysite.is/policy/';
149         set_config('sitepolicy', $mysitepolicy);
151         $result = external::get_config();
152         $result = external_api::clean_returnvalue(external::get_config_returns(), $result);
154         // SITE summary is null in phpunit which gets transformed to an empty string by format_text.
155         list($sitesummary, $unused) = external_format_text($SITE->summary, $SITE->summaryformat, context_system::instance()->id);
157         // Test default values.
158         $context = context_system::instance();
159         $expected = array(
160             array('name' => 'fullname', 'value' => $SITE->fullname),
161             array('name' => 'shortname', 'value' => $SITE->shortname),
162             array('name' => 'summary', 'value' => $sitesummary),
163             array('name' => 'summaryformat', 'value' => FORMAT_HTML),
164             array('name' => 'frontpage', 'value' => $CFG->frontpage),
165             array('name' => 'frontpageloggedin', 'value' => $CFG->frontpageloggedin),
166             array('name' => 'maxcategorydepth', 'value' => $CFG->maxcategorydepth),
167             array('name' => 'frontpagecourselimit', 'value' => $CFG->frontpagecourselimit),
168             array('name' => 'numsections', 'value' => course_get_format($SITE)->get_last_section_number()),
169             array('name' => 'newsitems', 'value' => $SITE->newsitems),
170             array('name' => 'commentsperpage', 'value' => $CFG->commentsperpage),
171             array('name' => 'sitepolicy', 'value' => $mysitepolicy),
172             array('name' => 'sitepolicyhandler', 'value' => ''),
173             array('name' => 'disableuserimages', 'value' => $CFG->disableuserimages),
174             array('name' => 'mygradesurl', 'value' => user_mygrades_url()->out(false)),
175             array('name' => 'tool_mobile_forcelogout', 'value' => 0),
176             array('name' => 'tool_mobile_customlangstrings', 'value' => ''),
177             array('name' => 'tool_mobile_disabledfeatures', 'value' => ''),
178             array('name' => 'tool_mobile_custommenuitems', 'value' => ''),
179             array('name' => 'tool_mobile_apppolicy', 'value' => ''),
180         );
181         $this->assertCount(0, $result['warnings']);
182         $this->assertEquals($expected, $result['settings']);
184         // Change a value and retrieve filtering by section.
185         set_config('commentsperpage', 1);
186         $expected[10]['value'] = 1;
187         // Remove not expected elements.
188         array_splice($expected, 11);
190         $result = external::get_config('frontpagesettings');
191         $result = external_api::clean_returnvalue(external::get_config_returns(), $result);
192         $this->assertCount(0, $result['warnings']);
193         $this->assertEquals($expected, $result['settings']);
194     }
196     /*
197      * Test get_autologin_key.
198      */
199     public function test_get_autologin_key() {
200         global $DB, $CFG, $USER;
202         $this->resetAfterTest(true);
204         $user = $this->getDataGenerator()->create_user();
205         $this->setUser($user);
206         $service = $DB->get_record('external_services', array('shortname' => MOODLE_OFFICIAL_MOBILE_SERVICE));
208         $token = external_generate_token_for_current_user($service);
210         // Check we got the private token.
211         $this->assertTrue(isset($token->privatetoken));
213         // Enable requeriments.
214         $_GET['wstoken'] = $token->token;   // Mock parameters.
216         // Fake the app.
217         core_useragent::instance(true, 'Mozilla/5.0 (Linux; Android 7.1.1; Moto G Play Build/NPIS26.48-43-2; wv) ' .
218                 'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36 MoodleMobile');
220         // Even if we force the password change for the current user we should be able to retrieve the key.
221         set_user_preference('auth_forcepasswordchange', 1, $user->id);
223         $this->setCurrentTimeStart();
224         $result = external::get_autologin_key($token->privatetoken);
225         $result = external_api::clean_returnvalue(external::get_autologin_key_returns(), $result);
226         // Validate the key.
227         $this->assertEquals(32, core_text::strlen($result['key']));
228         $key = $DB->get_record('user_private_key', array('value' => $result['key']));
229         $this->assertEquals($USER->id, $key->userid);
230         $this->assertTimeCurrent($key->validuntil - api::LOGIN_KEY_TTL);
232         // Now, try with an invalid private token.
233         set_user_preference('tool_mobile_autologin_request_last', time() - HOURSECS, $USER);
235         $this->expectException('moodle_exception');
236         $this->expectExceptionMessage(get_string('invalidprivatetoken', 'tool_mobile'));
237         $result = external::get_autologin_key(random_string('64'));
238     }
240     /**
241      * Test get_autologin_key missing ws.
242      */
243     public function test_get_autologin_key_missing_ws() {
244         global $CFG;
245         $this->resetAfterTest(true);
247         // Fake the app.
248         core_useragent::instance(true, 'Mozilla/5.0 (Linux; Android 7.1.1; Moto G Play Build/NPIS26.48-43-2; wv) ' .
249             'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36 MoodleMobile');
251         // Need to disable webservices to verify that's checked.
252         $CFG->enablewebservices = 0;
253         $CFG->enablemobilewebservice = 0;
255         $this->setAdminUser();
256         $this->expectException('moodle_exception');
257         $this->expectExceptionMessage(get_string('enablewsdescription', 'webservice'));
258         $result = external::get_autologin_key('');
259     }
261     /**
262      * Test get_autologin_key missing https.
263      */
264     public function test_get_autologin_key_missing_https() {
265         global $CFG;
267         // Fake the app.
268         core_useragent::instance(true, 'Mozilla/5.0 (Linux; Android 7.1.1; Moto G Play Build/NPIS26.48-43-2; wv) ' .
269             'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36 MoodleMobile');
271         // Need to simulate a non HTTPS site here.
272         $CFG->wwwroot = str_replace('https:', 'http:', $CFG->wwwroot);
274         $this->resetAfterTest(true);
275         $this->setAdminUser();
277         $this->expectException('moodle_exception');
278         $this->expectExceptionMessage(get_string('httpsrequired', 'tool_mobile'));
279         $result = external::get_autologin_key('');
280     }
282     /**
283      * Test get_autologin_key missing admin.
284      */
285     public function test_get_autologin_key_missing_admin() {
286         global $CFG;
288         $this->resetAfterTest(true);
289         $this->setAdminUser();
291         // Fake the app.
292         core_useragent::instance(true, 'Mozilla/5.0 (Linux; Android 7.1.1; Moto G Play Build/NPIS26.48-43-2; wv) ' .
293             'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36 MoodleMobile');
295         $this->expectException('moodle_exception');
296         $this->expectExceptionMessage(get_string('autologinnotallowedtoadmins', 'tool_mobile'));
297         $result = external::get_autologin_key('');
298     }
300     /**
301      * Test get_autologin_key locked.
302      */
303     public function test_get_autologin_key_missing_locked() {
304         global $CFG, $DB, $USER;
306         $this->resetAfterTest(true);
307         $user = $this->getDataGenerator()->create_user();
308         $this->setUser($user);
310         $service = $DB->get_record('external_services', array('shortname' => MOODLE_OFFICIAL_MOBILE_SERVICE));
312         $token = external_generate_token_for_current_user($service);
313         $_GET['wstoken'] = $token->token;   // Mock parameters.
315         // Fake the app.
316         core_useragent::instance(true, 'Mozilla/5.0 (Linux; Android 7.1.1; Moto G Play Build/NPIS26.48-43-2; wv) ' .
317             'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36 MoodleMobile');
319         $result = external::get_autologin_key($token->privatetoken);
320         $result = external_api::clean_returnvalue(external::get_autologin_key_returns(), $result);
322         // Mock last time request.
323         $mocktime = time() - 7 * MINSECS;
324         set_user_preference('tool_mobile_autologin_request_last', $mocktime, $USER);
325         $result = external::get_autologin_key($token->privatetoken);
326         $result = external_api::clean_returnvalue(external::get_autologin_key_returns(), $result);
328         // We just requested one token, we must wait.
329         $this->expectException('moodle_exception');
330         $this->expectExceptionMessage(get_string('autologinkeygenerationlockout', 'tool_mobile'));
331         $result = external::get_autologin_key($token->privatetoken);
332     }
334     /**
335      * Test get_autologin_key missing app_request.
336      */
337     public function test_get_autologin_key_missing_app_request() {
338         global $CFG;
340         $this->resetAfterTest(true);
341         $this->setAdminUser();
343         $this->expectException('moodle_exception');
344         $this->expectExceptionMessage(get_string('apprequired', 'tool_mobile'));
345         $result = external::get_autologin_key('');
346     }
348     /**
349      * Test get_content.
350      */
351     public function test_get_content() {
353         $paramval = 16;
354         $result = external::get_content('tool_mobile', 'test_view', array(array('name' => 'param1', 'value' => $paramval)));
355         $result = external_api::clean_returnvalue(external::get_content_returns(), $result);
356         $this->assertCount(1, $result['templates']);
357         $this->assertCount(1, $result['otherdata']);
358         $this->assertCount(2, $result['restrict']['users']);
359         $this->assertCount(2, $result['restrict']['courses']);
360         $this->assertEquals('alert();', $result['javascript']);
361         $this->assertEquals('main', $result['templates'][0]['id']);
362         $this->assertEquals('The HTML code', $result['templates'][0]['html']);
363         $this->assertEquals('otherdata1', $result['otherdata'][0]['name']);
364         $this->assertEquals($paramval, $result['otherdata'][0]['value']);
365         $this->assertEquals(array(1, 2), $result['restrict']['users']);
366         $this->assertEquals(array(3, 4), $result['restrict']['courses']);
367         $this->assertEmpty($result['files']);
368     }
370     /**
371      * Test get_content non existent function in valid component.
372      */
373     public function test_get_content_non_existent_function() {
375         $this->expectException('coding_exception');
376         $result = external::get_content('tool_mobile', 'test_blahblah');
377     }
379     /**
380      * Test get_content incorrect component.
381      */
382     public function test_get_content_invalid_component() {
384         $this->expectException('moodle_exception');
385         $result = external::get_content('tool_mobile\hack', 'test_view');
386     }
388     /**
389      * Test get_content non existent component.
390      */
391     public function test_get_content_non_existent_component() {
393         $this->expectException('moodle_exception');
394         $result = external::get_content('tool_blahblahblah', 'test_view');
395     }
397     public function test_call_external_functions() {
398         global $SESSION;
400         $this->resetAfterTest(true);
402         $category = self::getDataGenerator()->create_category(array('name' => 'Category 1'));
403         $course = self::getDataGenerator()->create_course([
404             'category' => $category->id,
405             'shortname' => 'c1',
406             'summary' => '<span lang="en" class="multilang">Course summary</span>'
407                 . '<span lang="eo" class="multilang">Kurso resumo</span>'
408                 . '@@PLUGINFILE@@/filename.txt'
409                 . '<!-- Comment stripped when formatting text -->',
410             'summaryformat' => FORMAT_MOODLE
411         ]);
412         $user1 = self::getDataGenerator()->create_user(['username' => 'user1', 'lastaccess' => time()]);
413         $user2 = self::getDataGenerator()->create_user(['username' => 'user2', 'lastaccess' => time()]);
415         self::setUser($user1);
417         // Setup WS token.
418         $webservicemanager = new \webservice;
419         $service = $webservicemanager->get_external_service_by_shortname(MOODLE_OFFICIAL_MOBILE_SERVICE);
420         $token = external_generate_token_for_current_user($service);
421         $_POST['wstoken'] = $token->token;
423         // Workaround for external_api::call_external_function requiring sesskey.
424         $_POST['sesskey'] = sesskey();
426         // Call some functions.
428         $requests = [
429             [
430                 'function' => 'core_course_get_courses_by_field',
431                 'arguments' => json_encode(['field' => 'id', 'value' => $course->id])
432             ],
433             [
434                 'function' => 'core_user_get_users_by_field',
435                 'arguments' => json_encode(['field' => 'id', 'values' => [$user1->id]])
436             ],
437             [
438                 'function' => 'core_user_get_user_preferences',
439                 'arguments' => json_encode(['name' => 'some_setting', 'userid' => $user2->id])
440             ],
441             [
442                 'function' => 'core_course_get_courses_by_field',
443                 'arguments' => json_encode(['field' => 'shortname', 'value' => $course->shortname])
444             ],
445         ];
446         $result = external::call_external_functions($requests);
448         // We need to execute the return values cleaning process to simulate the web service server.
449         $result = external_api::clean_returnvalue(external::call_external_functions_returns(), $result);
451         // Only 3 responses, the 4th request is not executed because the 3rd throws an exception.
452         $this->assertCount(3, $result['responses']);
454         $this->assertFalse($result['responses'][0]['error']);
455         $coursedata = external_api::clean_returnvalue(
456             core_course_external::get_courses_by_field_returns(),
457             core_course_external::get_courses_by_field('id', $course->id));
458          $this->assertEquals(json_encode($coursedata), $result['responses'][0]['data']);
460         $this->assertFalse($result['responses'][1]['error']);
461         $userdata = external_api::clean_returnvalue(
462             core_user_external::get_users_by_field_returns(),
463             core_user_external::get_users_by_field('id', [$user1->id]));
464         $this->assertEquals(json_encode($userdata), $result['responses'][1]['data']);
466         $this->assertTrue($result['responses'][2]['error']);
467         $exception = json_decode($result['responses'][2]['exception'], true);
468         $this->assertEquals('nopermissions', $exception['errorcode']);
470         // Call a function not included in the external service.
472         $_POST['wstoken'] = $token->token;
473         $functions = $webservicemanager->get_not_associated_external_functions($service->id);
474         $requests = [['function' => current($functions)->name]];
475         $result = external::call_external_functions($requests);
477         $this->assertTrue($result['responses'][0]['error']);
478         $exception = json_decode($result['responses'][0]['exception'], true);
479         $this->assertEquals('accessexception', $exception['errorcode']);
480         $this->assertEquals('webservice', $exception['module']);
482         // Call a function with different external settings.
484         filter_set_global_state('multilang', TEXTFILTER_ON);
485         $_POST['wstoken'] = $token->token;
486         $SESSION->lang = 'eo'; // Change default language, so we can test changing it to "en".
487         $requests = [
488             [
489                 'function' => 'core_course_get_courses_by_field',
490                 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]),
491             ],
492             [
493                 'function' => 'core_course_get_courses_by_field',
494                 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]),
495                 'settingraw' => '1'
496             ],
497             [
498                 'function' => 'core_course_get_courses_by_field',
499                 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]),
500                 'settingraw' => '1',
501                 'settingfileurl' => '0'
502             ],
503             [
504                 'function' => 'core_course_get_courses_by_field',
505                 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]),
506                 'settingfilter' => '1',
507                 'settinglang' => 'en'
508             ],
509         ];
510         $result = external::call_external_functions($requests);
512         $this->assertCount(4, $result['responses']);
514         $context = \context_course::instance($course->id);
515         $pluginfile = 'webservice/pluginfile.php';
517         $this->assertFalse($result['responses'][0]['error']);
518         $data = json_decode($result['responses'][0]['data']);
519         $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null);
520         $expected = format_text($expected, $course->summaryformat, ['para' => false, 'filter' => false]);
521         $this->assertEquals($expected, $data->courses[0]->summary);
523         $this->assertFalse($result['responses'][1]['error']);
524         $data = json_decode($result['responses'][1]['data']);
525         $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null);
526         $this->assertEquals($expected, $data->courses[0]->summary);
528         $this->assertFalse($result['responses'][2]['error']);
529         $data = json_decode($result['responses'][2]['data']);
530         $this->assertEquals($course->summary, $data->courses[0]->summary);
532         $this->assertFalse($result['responses'][3]['error']);
533         $data = json_decode($result['responses'][3]['data']);
534         $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null);
535         $SESSION->lang = 'en'; // We expect filtered text in english.
536         $expected = format_text($expected, $course->summaryformat, ['para' => false, 'filter' => true]);
537         $this->assertEquals($expected, $data->courses[0]->summary);
538     }