Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 402 and 403]

   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/>.
  16  
  17  namespace core_external;
  18  
  19  /**
  20   * Unit tests for core_external\external_api.
  21   *
  22   * @package     core_external
  23   * @category    test
  24   * @copyright   2022 Andrew Lyons <andrew@nicols.co.uk>
  25   * @license     http://www.gnu.org/copyleft/gpl.html GNU Public License
  26   * @covers      \core_external\external_api
  27   */
  28  class external_api_test extends \advanced_testcase {
  29      /**
  30       * Test the validate_parameters method.
  31       *
  32       * @covers \core_external\external_api::validate_parameters
  33       */
  34      public function test_validate_params(): void {
  35          $params = ['text' => 'aaa', 'someid' => '6'];
  36          $description = new external_function_parameters([
  37              'someid' => new external_value(PARAM_INT, 'Some int value'),
  38              'text'   => new external_value(PARAM_ALPHA, 'Some text value'),
  39          ]);
  40          $result = external_api::validate_parameters($description, $params);
  41          $this->assertCount(2, $result);
  42          reset($result);
  43          $this->assertSame('someid', key($result));
  44          $this->assertSame(6, $result['someid']);
  45          $this->assertSame('aaa', $result['text']);
  46  
  47          $params = [
  48              'someids' => ['1', 2, 'a' => '3'],
  49              'scalar' => 666,
  50          ];
  51          $description = new external_function_parameters([
  52              'someids' => new external_multiple_structure(new external_value(PARAM_INT, 'Some ID')),
  53              'scalar'  => new external_value(PARAM_ALPHANUM, 'Some text value'),
  54          ]);
  55          $result = external_api::validate_parameters($description, $params);
  56          $this->assertCount(2, $result);
  57          reset($result);
  58          $this->assertSame('someids', key($result));
  59          $this->assertEquals([0 => 1, 1 => 2, 2 => 3], $result['someids']);
  60          $this->assertSame('666', $result['scalar']);
  61  
  62          $params = ['text' => 'aaa'];
  63          $description = new external_function_parameters([
  64              'someid' => new external_value(PARAM_INT, 'Some int value', VALUE_DEFAULT),
  65              'text'   => new external_value(PARAM_ALPHA, 'Some text value'),
  66          ]);
  67          $result = external_api::validate_parameters($description, $params);
  68          $this->assertCount(2, $result);
  69          reset($result);
  70          $this->assertSame('someid', key($result));
  71          $this->assertNull($result['someid']);
  72          $this->assertSame('aaa', $result['text']);
  73  
  74          $params = ['text' => 'aaa'];
  75          $description = new external_function_parameters([
  76              'someid' => new external_value(PARAM_INT, 'Some int value', VALUE_DEFAULT, 6),
  77              'text'   => new external_value(PARAM_ALPHA, 'Some text value'),
  78          ]);
  79          $result = external_api::validate_parameters($description, $params);
  80          $this->assertCount(2, $result);
  81          reset($result);
  82          $this->assertSame('someid', key($result));
  83          $this->assertSame(6, $result['someid']);
  84          $this->assertSame('aaa', $result['text']);
  85  
  86          // Missing required value (an exception is thrown).
  87          $testdata = [];
  88          try {
  89              external_api::clean_returnvalue($description, $testdata);
  90              $this->fail('Exception expected');
  91          } catch (\moodle_exception $ex) {
  92              $this->assertInstanceOf(\invalid_response_exception::class, $ex);
  93              $this->assertSame('Invalid response value detected (Error in response - '
  94                  . 'Missing following required key in a single structure: text)', $ex->getMessage());
  95          }
  96  
  97          // Test nullable external_value may optionally return data.
  98          $description = new external_function_parameters([
  99              'value' => new external_value(PARAM_INT, '', VALUE_REQUIRED, null, NULL_ALLOWED)
 100          ]);
 101          $testdata = ['value' => null];
 102          $cleanedvalue = external_api::clean_returnvalue($description, $testdata);
 103          $this->assertSame($testdata, $cleanedvalue);
 104          $testdata = ['value' => 1];
 105          $cleanedvalue = external_api::clean_returnvalue($description, $testdata);
 106          $this->assertSame($testdata, $cleanedvalue);
 107  
 108          // Test nullable external_single_structure may optionally return data.
 109          $description = new external_function_parameters([
 110              'value' => new external_single_structure(['value2' => new external_value(PARAM_INT)],
 111                  '', VALUE_REQUIRED, null, NULL_ALLOWED)
 112          ]);
 113          $testdata = ['value' => null];
 114          $cleanedvalue = external_api::clean_returnvalue($description, $testdata);
 115          $this->assertSame($testdata, $cleanedvalue);
 116          $testdata = ['value' => ['value2' => 1]];
 117          $cleanedvalue = external_api::clean_returnvalue($description, $testdata);
 118          $this->assertSame($testdata, $cleanedvalue);
 119  
 120          // Test nullable external_multiple_structure may optionally return data.
 121          $description = new external_function_parameters([
 122              'value' => new external_multiple_structure(
 123                  new external_value(PARAM_INT), '', VALUE_REQUIRED, null, NULL_ALLOWED)
 124          ]);
 125          $testdata = ['value' => null];
 126          $cleanedvalue = external_api::clean_returnvalue($description, $testdata);
 127          $this->assertSame($testdata, $cleanedvalue);
 128          $testdata = ['value' => [1]];
 129          $cleanedvalue = external_api::clean_returnvalue($description, $testdata);
 130          $this->assertSame($testdata, $cleanedvalue);
 131      }
 132  
 133      /**
 134       * Test for clean_returnvalue() for testing that returns the PHP type.
 135       *
 136       * @covers \core_external\external_api::clean_returnvalue
 137       */
 138      public function test_clean_returnvalue_return_php_type(): void {
 139          $returndesc = new external_single_structure([
 140              'value' => new external_value(PARAM_RAW, 'Some text', VALUE_OPTIONAL, null, NULL_NOT_ALLOWED),
 141          ]);
 142  
 143          // Check return type on exception because the external values does not allow NULL values.
 144          $testdata = ['value' => null];
 145          try {
 146              $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 147          } catch (\moodle_exception $e) {
 148              $this->assertInstanceOf(\invalid_response_exception::class, $e);
 149              $this->assertStringContainsString('of PHP type "NULL"', $e->debuginfo);
 150          }
 151      }
 152  
 153      /**
 154       * Test for clean_returnvalue().
 155       *
 156       * @covers \core_external\external_api::clean_returnvalue
 157       */
 158      public function test_clean_returnvalue(): void {
 159          // Build some return value decription.
 160          $returndesc = new external_multiple_structure(
 161              new external_single_structure(
 162                  [
 163                      'object' => new external_single_structure(
 164                                  ['value1' => new external_value(PARAM_INT, 'this is a int')]),
 165                      'value2' => new external_value(PARAM_TEXT, 'some text', VALUE_OPTIONAL),
 166                  ]
 167              ));
 168  
 169          // Clean an object (it should be cast into an array).
 170          $object = new \stdClass();
 171          $object->value1 = 1;
 172          $singlestructure['object'] = $object;
 173          $singlestructure['value2'] = 'Some text';
 174          $testdata = [$singlestructure];
 175          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 176          $cleanedsinglestructure = array_pop($cleanedvalue);
 177          $this->assertSame($object->value1, $cleanedsinglestructure['object']['value1']);
 178          $this->assertSame($singlestructure['value2'], $cleanedsinglestructure['value2']);
 179  
 180          // Missing VALUE_OPTIONAL.
 181          $object = new \stdClass();
 182          $object->value1 = 1;
 183          $singlestructure = new \stdClass();
 184          $singlestructure->object = $object;
 185          $testdata = [$singlestructure];
 186          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 187          $cleanedsinglestructure = array_pop($cleanedvalue);
 188          $this->assertSame($object->value1, $cleanedsinglestructure['object']['value1']);
 189          $this->assertArrayNotHasKey('value2', $cleanedsinglestructure);
 190  
 191          // Unknown attribute (the value should be ignored).
 192          $object = [];
 193          $object['value1'] = 1;
 194          $singlestructure = [];
 195          $singlestructure['object'] = $object;
 196          $singlestructure['value2'] = 'Some text';
 197          $singlestructure['unknownvalue'] = 'Some text to ignore';
 198          $testdata = [$singlestructure];
 199          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 200          $cleanedsinglestructure = array_pop($cleanedvalue);
 201          $this->assertSame($object['value1'], $cleanedsinglestructure['object']['value1']);
 202          $this->assertSame($singlestructure['value2'], $cleanedsinglestructure['value2']);
 203          $this->assertArrayNotHasKey('unknownvalue', $cleanedsinglestructure);
 204  
 205          // Missing required value (an exception is thrown).
 206          $object = [];
 207          $singlestructure = [];
 208          $singlestructure['object'] = $object;
 209          $singlestructure['value2'] = 'Some text';
 210          $testdata = [$singlestructure];
 211          try {
 212              external_api::clean_returnvalue($returndesc, $testdata);
 213              $this->fail('Exception expected');
 214          } catch (\moodle_exception $ex) {
 215              $this->assertInstanceOf(\invalid_response_exception::class, $ex);
 216              $this->assertSame('Invalid response value detected (object => Invalid response value detected '
 217                  . '(Error in response - Missing following required key in a single structure: value1): Error in response - '
 218                  . 'Missing following required key in a single structure: value1)', $ex->getMessage());
 219          }
 220  
 221          // Fail if no data provided when value required.
 222          $testdata = null;
 223          try {
 224              external_api::clean_returnvalue($returndesc, $testdata);
 225              $this->fail('Exception expected');
 226          } catch (\moodle_exception $ex) {
 227              $this->assertInstanceOf(\invalid_response_exception::class, $ex);
 228              $this->assertSame('Invalid response value detected (Only arrays accepted. The bad value is: \'\')',
 229                  $ex->getMessage());
 230          }
 231  
 232          // Test nullable external_multiple_structure may optionally return data.
 233          $returndesc = new external_multiple_structure(
 234              new external_value(PARAM_INT),
 235              '', VALUE_REQUIRED, null, NULL_ALLOWED);
 236          $testdata = null;
 237          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 238          $this->assertSame($testdata, $cleanedvalue);
 239          $testdata = [1];
 240          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 241          $this->assertSame($testdata, $cleanedvalue);
 242  
 243          // Test nullable external_single_structure may optionally return data.
 244          $returndesc = new external_single_structure(['value' => new external_value(PARAM_INT)],
 245              '', VALUE_REQUIRED, null, NULL_ALLOWED);
 246          $testdata = null;
 247          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 248          $this->assertSame($testdata, $cleanedvalue);
 249          $testdata = ['value' => 1];
 250          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 251          $this->assertSame($testdata, $cleanedvalue);
 252  
 253          // Test nullable external_value may optionally return data.
 254          $returndesc = new external_value(PARAM_INT, '', VALUE_REQUIRED, null, NULL_ALLOWED);
 255          $testdata = null;
 256          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 257          $this->assertSame($testdata, $cleanedvalue);
 258          $testdata = 1;
 259          $cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
 260          $this->assertSame($testdata, $cleanedvalue);
 261      }
 262  
 263      /**
 264       * Test \core_external\external_api::get_context_from_params().
 265       *
 266       * @covers \core_external\external_api::get_context_from_params
 267       */
 268      public function test_get_context_from_params(): void {
 269          $this->resetAfterTest(true);
 270          $course = $this->getDataGenerator()->create_course();
 271          $realcontext = \context_course::instance($course->id);
 272  
 273          // Use context id.
 274          $fetchedcontext = $this->get_context_from_params(["contextid" => $realcontext->id]);
 275          $this->assertEquals($realcontext, $fetchedcontext);
 276  
 277          // Use context level and instance id.
 278          $fetchedcontext = $this->get_context_from_params(["contextlevel" => "course", "instanceid" => $course->id]);
 279          $this->assertEquals($realcontext, $fetchedcontext);
 280  
 281          // Use context level numbers instead of legacy short level names.
 282          $fetchedcontext = $this->get_context_from_params(
 283              ["contextlevel" => \core\context\course::LEVEL, "instanceid" => $course->id]);
 284          $this->assertEquals($realcontext, $fetchedcontext);
 285  
 286          // Passing empty values.
 287          try {
 288              $fetchedcontext = $this->get_context_from_params(["contextid" => 0]);
 289              $this->fail('Exception expected from get_context_wrapper()');
 290          } catch (\moodle_exception $e) {
 291              $this->assertInstanceOf(\invalid_parameter_exception::class, $e);
 292          }
 293  
 294          try {
 295              $fetchedcontext = $this->get_context_from_params(["instanceid" => 0]);
 296              $this->fail('Exception expected from get_context_wrapper()');
 297          } catch (\moodle_exception $e) {
 298              $this->assertInstanceOf(\invalid_parameter_exception::class, $e);
 299          }
 300  
 301          try {
 302              $fetchedcontext = $this->get_context_from_params(["contextid" => null]);
 303              $this->fail('Exception expected from get_context_wrapper()');
 304          } catch (\moodle_exception $e) {
 305              $this->assertInstanceOf(\invalid_parameter_exception::class, $e);
 306          }
 307  
 308          // Tests for context with instanceid equal to 0 (System context).
 309          $realcontext = \context_system::instance();
 310          $fetchedcontext = $this->get_context_from_params(["contextlevel" => "system", "instanceid" => 0]);
 311          $this->assertEquals($realcontext, $fetchedcontext);
 312  
 313          // Passing wrong level name.
 314          try {
 315              $fetchedcontext = $this->get_context_from_params(["contextlevel" => "random", "instanceid" => $course->id]);
 316              $this->fail('exception expected when level name is invalid');
 317          } catch (\moodle_exception $e) {
 318              $this->assertInstanceOf('invalid_parameter_exception', $e);
 319              $this->assertSame('Invalid parameter value detected (Invalid context level = random)', $e->getMessage());
 320          }
 321  
 322          // Passing wrong level number.
 323          try {
 324              $fetchedcontext = $this->get_context_from_params(["contextlevel" => -10, "instanceid" => $course->id]);
 325              $this->fail('exception expected when level name is invalid');
 326          } catch (\moodle_exception $e) {
 327              $this->assertInstanceOf('invalid_parameter_exception', $e);
 328              $this->assertSame('Invalid parameter value detected (Invalid context level = -10)', $e->getMessage());
 329          }
 330      }
 331  
 332      /**
 333       * Test \core_external\external_api::get_context()_from_params parameter validation.
 334       *
 335       * @covers \core_external\external_api::get_context
 336       */
 337      public function test_get_context_params(): void {
 338          global $USER;
 339  
 340          // Call without correct context details.
 341          $this->expectException('invalid_parameter_exception');
 342          $this->get_context_from_params(['roleid' => 3, 'userid' => $USER->id]);
 343      }
 344  
 345      /**
 346       * Test \core_external\external_api::get_context()_from_params parameter validation.
 347       *
 348       * @covers \core_external\external_api::get_context
 349       */
 350      public function test_get_context_params2(): void {
 351          global $USER;
 352  
 353          // Call without correct context details.
 354          $this->expectException('invalid_parameter_exception');
 355          $this->get_context_from_params(['roleid' => 3, 'userid' => $USER->id, 'contextlevel' => "course"]);
 356      }
 357  
 358      /**
 359       * Test \core_external\external_api::get_context()_from_params parameter validation.
 360       * @covers \core_external\external_api::get_context
 361       */
 362      public function test_get_context_params3(): void {
 363          global $USER;
 364  
 365          // Call without correct context details.
 366          $this->resetAfterTest(true);
 367          $course = self::getDataGenerator()->create_course();
 368          $this->expectException('invalid_parameter_exception');
 369          $this->get_context_from_params(['roleid' => 3, 'userid' => $USER->id, 'instanceid' => $course->id]);
 370      }
 371  
 372      /**
 373       * Data provider for the test_all_external_info test.
 374       *
 375       * @return array
 376       */
 377      public function all_external_info_provider(): array {
 378          global $DB;
 379  
 380          // We are testing here that all the external function descriptions can be generated without
 381          // producing warnings. E.g. misusing optional params will generate a debugging message which
 382          // will fail this test.
 383          $functions = $DB->get_records('external_functions', [], 'name');
 384          $return = [];
 385          foreach ($functions as $f) {
 386              $return[$f->name] = [$f];
 387          }
 388          return $return;
 389      }
 390  
 391      /**
 392       * Test \core_external\external_api::external_function_info.
 393       *
 394       * @runInSeparateProcess
 395       * @dataProvider all_external_info_provider
 396       * @covers \core_external\external_api::external_function_info
 397       * @param \stdClass $definition
 398       */
 399      public function test_all_external_info(\stdClass $definition): void {
 400          $desc = external_api::external_function_info($definition);
 401          $this->assertNotEmpty($desc->name);
 402          $this->assertNotEmpty($desc->classname);
 403          $this->assertNotEmpty($desc->methodname);
 404          $this->assertEquals($desc->component, clean_param($desc->component, PARAM_COMPONENT));
 405          $this->assertInstanceOf(external_function_parameters::class, $desc->parameters_desc);
 406          if ($desc->returns_desc != null) {
 407              $this->assertInstanceOf(external_description::class, $desc->returns_desc);
 408          }
 409      }
 410  
 411      /**
 412       * Test the \core_external\external_api::call_external_function() function.
 413       *
 414       * @covers \core_external\external_api::call_external_function
 415       */
 416      public function test_call_external_function(): void {
 417          global $PAGE, $COURSE, $CFG;
 418  
 419          $this->resetAfterTest(true);
 420  
 421          // Call some webservice functions and verify they are correctly handling $PAGE and $COURSE.
 422          // First test a function that calls validate_context outside a course.
 423          $this->setAdminUser();
 424          $category = $this->getDataGenerator()->create_category();
 425          $params = [
 426              'contextid' => \context_coursecat::instance($category->id)->id,
 427              'name' => 'aaagrrryyy',
 428              'idnumber' => '',
 429              'description' => '',
 430          ];
 431          $cohort1 = $this->getDataGenerator()->create_cohort($params);
 432          $cohort2 = $this->getDataGenerator()->create_cohort();
 433  
 434          $beforepage = $PAGE;
 435          $beforecourse = $COURSE;
 436          $params = ['cohortids' => [$cohort1->id, $cohort2->id]];
 437          $result = external_api::call_external_function('core_cohort_get_cohorts', $params);
 438  
 439          $this->assertSame($beforepage, $PAGE);
 440          $this->assertSame($beforecourse, $COURSE);
 441  
 442          // Now test a function that calls validate_context inside a course.
 443          $course = $this->getDataGenerator()->create_course();
 444  
 445          $beforepage = $PAGE;
 446          $beforecourse = $COURSE;
 447          $params = ['courseid' => $course->id, 'options' => []];
 448          $result = external_api::call_external_function('core_enrol_get_enrolled_users', $params);
 449  
 450          $this->assertSame($beforepage, $PAGE);
 451          $this->assertSame($beforecourse, $COURSE);
 452  
 453          // Test a function that triggers a PHP exception.
 454          require_once($CFG->dirroot . '/lib/tests/fixtures/test_external_function_throwable.php');
 455  
 456          // Call our test function.
 457          $result = \test_external_function_throwable::call_external_function('core_throw_exception', [], false);
 458  
 459          $this->assertTrue($result['error']);
 460          $this->assertArrayHasKey('exception', $result);
 461          $this->assertEquals($result['exception']->message, 'Exception - Modulo by zero');
 462      }
 463  
 464      /**
 465       * Call the get_contect_from_params methods on the api class.
 466       *
 467       * @return mixed
 468       */
 469      protected function get_context_from_params() {
 470          $rc = new \ReflectionClass(external_api::class);
 471          $method = $rc->getMethod('get_context_from_params');
 472          $method->setAccessible(true);
 473          return $method->invokeArgs(null, func_get_args());
 474      }
 475  }