Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   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  use enrol_lti\helper;
  18  use enrol_lti\local\ltiadvantage\entity\application_registration;
  19  use enrol_lti\local\ltiadvantage\repository\application_registration_repository;
  20  use enrol_lti\local\ltiadvantage\repository\context_repository;
  21  use enrol_lti\local\ltiadvantage\repository\deployment_repository;
  22  use enrol_lti\local\ltiadvantage\repository\resource_link_repository;
  23  use enrol_lti\local\ltiadvantage\repository\user_repository;
  24  use enrol_lti\local\ltiadvantage\service\tool_launch_service;
  25  use Packback\Lti1p3\LtiMessageLaunch;
  26  
  27  /**
  28   * Parent class for LTI Advantage tests, providing environment setup and mock user launches.
  29   *
  30   * @package    enrol_lti
  31   * @copyright  2021 Jake Dallimore <jrhdallimore@gmail.com>
  32   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  33   */
  34  abstract class lti_advantage_testcase extends \advanced_testcase {
  35  
  36      /** @var string the default issuer for tests extending this class. */
  37      protected $issuer = 'https://lms.example.org';
  38  
  39      /**
  40       * Helper to return a user which has been bound to the LTI credentials provided and is deemed a valid linked user.
  41       *
  42       * @param string $sub the subject id string
  43       * @param array $migrationclaiminfo mocked migration claim information, allowing the mock auth to bind to an existing user.
  44       * @return stdClass the user record.
  45       */
  46      protected function lti_advantage_user_authenticates(string $sub, array $migrationclaiminfo = []): \stdClass {
  47          $auth = get_auth_plugin('lti');
  48  
  49          $mockjwt = [
  50              'iss' => $this->issuer,
  51              'sub' => $sub,
  52              'https://purl.imsglobal.org/spec/lti/claim/deployment_id' => '1222', // Must match deployment in create_test_env.
  53              'aud' => '123', // Must match registration in create_test_environment.
  54              'exp' => time() + 60,
  55              'nonce' => 'some-nonce-value-123',
  56              'given_name' => 'John',
  57              'family_name' => 'Smith',
  58              'email' => 'smithj@example.org'
  59          ];
  60          if (!empty($migrationclaiminfo)) {
  61              if (isset($migrationclaiminfo['consumer_key'])) {
  62                  $base = [
  63                      $migrationclaiminfo['consumer_key'],
  64                      $mockjwt['https://purl.imsglobal.org/spec/lti/claim/deployment_id'],
  65                      $mockjwt['iss'],
  66                      $mockjwt['aud'],
  67                      $mockjwt['exp'],
  68                      $mockjwt['nonce']
  69                  ];
  70                  $basestring = implode('&', $base);
  71  
  72                  $mockjwt['https://purl.imsglobal.org/spec/lti/claim/lti1p1'] = [
  73                      'oauth_consumer_key' => $migrationclaiminfo['consumer_key'],
  74                  ];
  75  
  76                  if (isset($migrationclaiminfo['signing_secret'])) {
  77                      $sig = base64_encode(hash_hmac('sha256', $basestring, $migrationclaiminfo['signing_secret']));
  78                      $mockjwt['https://purl.imsglobal.org/spec/lti/claim/lti1p1']['oauth_consumer_key_sign'] = $sig;
  79                  }
  80              }
  81  
  82              $claimprops = ['user_id', 'context_id', 'tool_consumer_instance_guid', 'resource_link_id'];
  83              foreach ($claimprops as $prop) {
  84                  if (!empty($migrationclaiminfo[$prop])) {
  85                      $mockjwt['https://purl.imsglobal.org/spec/lti/claim/lti1p1'][$prop] =
  86                          $migrationclaiminfo[$prop];
  87                  }
  88              }
  89          }
  90  
  91          $secrets = !empty($migrationclaiminfo['signing_secret']) ? [$migrationclaiminfo['signing_secret']] : [];
  92          return $auth->find_or_create_user_from_launch($mockjwt, false, $secrets);
  93      }
  94  
  95      /**
  96       * Get a list of users ready for use with mock launches by providing an array of user ids.
  97       *
  98       * @param array $ids the platform user_ids for the users.
  99       * @param bool $includepicture whether to include a profile picture or not (slows tests, so defaults to false).
 100       * @param string $role the LTI role to include in the user data.
 101       * @return array the users list.
 102       */
 103      protected function get_mock_launch_users_with_ids(array $ids, bool $includepicture = false,
 104              string $role = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'): array {
 105  
 106          $users = [];
 107          foreach ($ids as $id) {
 108              $user = [
 109                  'user_id' => $id,
 110                  'given_name' => 'Firstname' . $id,
 111                  'family_name' => 'Surname' . $id,
 112                  'email' => "firstname.surname{$id}@lms.example.org",
 113                  'roles' => [$role]
 114              ];
 115              if ($includepicture) {
 116                  $user['picture'] = $this->getExternalTestFileUrl('/test.jpg');
 117              }
 118              $users[] = $user;
 119          }
 120          return $users;
 121      }
 122  
 123      /**
 124       * Get a mock LtiMessageLaunch object, as if a user had launched from a resource link in the platform.
 125       *
 126       * @param \stdClass $resource the resource record, allowing the mock to generate a link to this.
 127       * @param array $mockuser the user on the platform who is performing the launch.
 128       * @param string|null $resourcelinkid the id of resource link in the platform, if desired.
 129       * @param array|null $ags array representing the lti-ags claim info. Pass null to omit, empty array to use a default.
 130       * @param bool $nrps whether to include a mock NRPS claim or not.
 131       * @param array|null $migrationclaiminfo contains consumer key, secret and any fields which are sent in the claim.
 132       * @param array|null $customparams an array of custom params to send, or null to just use defaults.
 133       * @param mixed $aud the array or string value of aud to use in the mock launch data.
 134       * @return LtiMessageLaunch the mock launch object with test launch data.
 135       */
 136      protected function get_mock_launch(\stdClass $resource, array $mockuser,
 137              ?string $resourcelinkid = null, ?array $ags = [], bool $nrps = true, ?array $migrationclaiminfo = null,
 138              ?array $customparams = null, $aud = '123'): LtiMessageLaunch {
 139  
 140          $mocklaunch = $this->getMockBuilder(LtiMessageLaunch::class)
 141              ->onlyMethods(['getLaunchData'])
 142              ->disableOriginalConstructor()
 143              ->getMock();
 144          $mocklaunch->expects($this->any())
 145              ->method('getLaunchData')
 146              ->will($this->returnCallback(
 147                  function()
 148                  use ($resource, $mockuser, $resourcelinkid, $migrationclaiminfo, $ags, $nrps, $customparams, $aud) {
 149                      // This simulates the data in the jwt['body'] of a real resource link launch.
 150                      // Real launches would of course have this data and authenticity of the user verified.
 151                      $rltitle = $resourcelinkid ? "Resource link $resourcelinkid in platform" : "Resource link in platform";
 152                      $rlid = $resourcelinkid ?: '12345';
 153                      $data = [
 154                          'iss' => $this->issuer, // Must match registration in create_test_environment.
 155                          'aud' => $aud, // Must match registration in create_test_environment.
 156                          'sub' => $mockuser['user_id'], // User id on the platform site.
 157                          'exp' => time() + 60,
 158                          'nonce' => 'some-nonce-value-123',
 159                          'https://purl.imsglobal.org/spec/lti/claim/deployment_id' => '1', // Must match registration.
 160                          'https://purl.imsglobal.org/spec/lti/claim/roles' =>
 161                              $mockuser['roles'] ?? ['http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'],
 162                          'https://purl.imsglobal.org/spec/lti/claim/resource_link' => [
 163                              'title' => $rltitle,
 164                              'id' => $rlid, // Arbitrary, will be mapped to the user during resource link launch.
 165                          ],
 166                          "https://purl.imsglobal.org/spec/lti/claim/context" => [
 167                              "id" => "context-id-12345",
 168                              "label" => "ITS 123",
 169                              "title" => "ITS 123 Machine Learning",
 170                              "type" => ["http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering"]
 171                          ],
 172                          'https://purl.imsglobal.org/spec/lti/claim/target_link_uri' =>
 173                              'https://this-moodle-tool.example.org/context/24/resource/14',
 174                          'given_name' => $mockuser['given_name'],
 175                          'family_name' => $mockuser['family_name'],
 176                          'email' => $mockuser['email'],
 177                      ];
 178  
 179                      if (!is_null($customparams)) {
 180                          $data['https://purl.imsglobal.org/spec/lti/claim/custom'] = $customparams;
 181                      } else {
 182                          $data['https://purl.imsglobal.org/spec/lti/claim/custom'] = [
 183                              'id' => $resource->uuid,
 184                          ];
 185                      }
 186  
 187                      if (is_array($ags)) {
 188                          if (empty($ags)) {
 189                              $agsclaim = [
 190                                  "scope" => [
 191                                      "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
 192                                      "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly",
 193                                      "https://purl.imsglobal.org/spec/lti-ags/scope/score"
 194                                  ],
 195                                  "lineitems" => "https://platform.example.com/10/lineitems/",
 196                                  "lineitem" => "https://platform.example.com/10/lineitems/45/lineitem"
 197                              ];
 198                          } else {
 199                              $agsclaim = $ags;
 200                          }
 201                          $data["https://purl.imsglobal.org/spec/lti-ags/claim/endpoint"] = $agsclaim;
 202                      }
 203  
 204                      if ($nrps) {
 205                          $data['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice'] = [
 206                              'context_memberships_url' => 'https://lms.example.org/context/24/memberships',
 207                              'service_versions' => ['2.0']
 208                          ];
 209                      }
 210  
 211                      if (!empty($mockuser['picture'])) {
 212                          $data['picture'] = $mockuser['picture'];
 213                      }
 214  
 215                      if ($migrationclaiminfo) {
 216                          if (isset($migrationclaiminfo['consumer_key'])) {
 217                              $base = [
 218                                  $migrationclaiminfo['consumer_key'],
 219                                  $data['https://purl.imsglobal.org/spec/lti/claim/deployment_id'],
 220                                  $data['iss'],
 221                                  $data['aud'],
 222                                  $data['exp'],
 223                                  $data['nonce']
 224                              ];
 225                              $basestring = implode('&', $base);
 226  
 227                              $data['https://purl.imsglobal.org/spec/lti/claim/lti1p1'] = [
 228                                  'oauth_consumer_key' => $migrationclaiminfo['consumer_key'],
 229                              ];
 230  
 231                              if (isset($migrationclaiminfo['signing_secret'])) {
 232                                  $sig = base64_encode(hash_hmac('sha256', $basestring, $migrationclaiminfo['signing_secret']));
 233                                  $data['https://purl.imsglobal.org/spec/lti/claim/lti1p1']['oauth_consumer_key_sign'] = $sig;
 234                              }
 235                          }
 236  
 237                          $claimprops = ['user_id', 'context_id', 'tool_consumer_instance_guid', 'resource_link_id'];
 238                          foreach ($claimprops as $prop) {
 239                              if (!empty($migrationclaiminfo[$prop])) {
 240                                  $data['https://purl.imsglobal.org/spec/lti/claim/lti1p1'][$prop] =
 241                                      $migrationclaiminfo[$prop];
 242                              }
 243                          }
 244                      }
 245                      return $data;
 246                  }
 247              ));
 248  
 249          return $mocklaunch;
 250      }
 251  
 252      /**
 253       * Sets up and returns a test course, including LTI-published resources, ready for testing.
 254       *
 255       * @param bool $enableauthplugin whether to enable the auth plugin during setup.
 256       * @param bool $enableenrolplugin whether to enable the enrol plugin during setup.
 257       * @param bool $membersync whether or not the published resource support membership sync with the platform.
 258       * @param int $membersyncmode the mode of member sync to set up on the shared resource.
 259       * @param bool $gradesync whether or not to enabled gradesync on the published resources.
 260       * @param bool $gradesynccompletion whether or not to require gradesynccompletion on the published resources.
 261       * @param int $enrolstartdate the unix time when the enrolment starts, or 0 for no start time.
 262       * @param int $provisioningmodeinstructor the teacher provisioning mode for all created resources, 0 for default (prompt).
 263       * @param int $provisioningmodelearner the student provisioning mode for all created resources, 0 for default (auto).
 264       * @return array array of objects for use in individual tests; courses, tools.
 265       */
 266      protected function create_test_environment(bool $enableauthplugin = true, bool $enableenrolplugin = true,
 267              bool $membersync = true, int $membersyncmode = helper::MEMBER_SYNC_ENROL_AND_UNENROL,
 268              bool $gradesync = true, bool $gradesynccompletion = false, int $enrolstartdate = 0, int $provisioningmodeinstructor = 0,
 269              int $provisioningmodelearner = 0): array {
 270  
 271          global $CFG;
 272          require_once($CFG->libdir . '/completionlib.php');
 273          require_once($CFG->dirroot . '/auth/lti/auth.php');
 274  
 275          if ($enableauthplugin) {
 276              $this->enable_auth();
 277          }
 278          if ($enableenrolplugin) {
 279              $this->enable_enrol();
 280          }
 281  
 282          // Set up the registration and deployment.
 283          $reg = application_registration::create(
 284              'Example LMS application',
 285              'a2c94a2c94',
 286              new moodle_url($this->issuer),
 287              '123',
 288              new moodle_url('https://example.org/authrequesturl'),
 289              new moodle_url('https://example.org/jwksurl'),
 290              new moodle_url('https://example.org/accesstokenurl')
 291          );
 292          $regrepo = new application_registration_repository();
 293          $reg = $regrepo->save($reg);
 294          $deployment = $reg->add_tool_deployment('My tool deployment', '1');
 295          $deploymentrepo = new deployment_repository();
 296          $deployment = $deploymentrepo->save($deployment);
 297  
 298          $generator = $this->getDataGenerator();
 299          $course = $generator->create_course(['enablecompletion' => 1]);
 300  
 301          // Create a module and publish it.
 302          $mod = $generator->create_module('assign', ['course' => $course->id, 'grade' => 100, 'completionsubmit' => 1,
 303              'completion' => COMPLETION_TRACKING_AUTOMATIC]);
 304          $tooldata = [
 305              'cmid' => $mod->cmid,
 306              'courseid' => $course->id,
 307              'membersyncmode' => $membersyncmode,
 308              'membersync' => $membersync,
 309              'gradesync' => $gradesync,
 310              'gradesynccompletion' => $gradesynccompletion,
 311              'ltiversion' => 'LTI-1p3',
 312              'enrolstartdate' => $enrolstartdate,
 313              'provisioningmodeinstructor' => $provisioningmodeinstructor ?: auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING,
 314              'provisioningmodelearner' => $provisioningmodelearner ?: auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY
 315          ];
 316          $tool = $generator->create_lti_tool((object)$tooldata);
 317          $tool = helper::get_lti_tool($tool->id);
 318  
 319          // Create a second module and publish it.
 320          $mod = $generator->create_module('assign', ['course' => $course->id, 'grade' => 100, 'completionsubmit' => 1,
 321              'completion' => COMPLETION_TRACKING_AUTOMATIC]);
 322          $tooldata = [
 323              'cmid' => $mod->cmid,
 324              'courseid' => $course->id,
 325              'membersyncmode' => $membersyncmode,
 326              'membersync' => $membersync,
 327              'gradesync' => $gradesync,
 328              'gradesynccompletion' => $gradesynccompletion,
 329              'ltiversion' => 'LTI-1p3',
 330              'enrolstartdate' => $enrolstartdate,
 331              'provisioningmodeinstructor' => $provisioningmodeinstructor ?: auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING,
 332              'provisioningmodelearner' => $provisioningmodelearner ?: auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY
 333          ];
 334          $tool2 = $generator->create_lti_tool((object)$tooldata);
 335          $tool2 = helper::get_lti_tool($tool2->id);
 336  
 337          // Create a course and publish it.
 338          $tooldata = [
 339              'courseid' => $course->id,
 340              'membersyncmode' => $membersyncmode,
 341              'membersync' => $membersync,
 342              'gradesync' => $gradesync,
 343              'gradesynccompletion' => $gradesynccompletion,
 344              'ltiversion' => 'LTI-1p3',
 345              'enrolstartdate' => $enrolstartdate,
 346              'provisioningmodeinstructor' => $provisioningmodeinstructor ?: auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING,
 347              'provisioningmodelearner' => $provisioningmodelearner ?: auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY
 348          ];
 349          $tool3 = $generator->create_lti_tool((object)$tooldata);
 350          $tool3 = helper::get_lti_tool($tool3->id);
 351  
 352          return [$course, $tool, $tool2, $tool3, $reg, $deployment];
 353      }
 354  
 355      /**
 356       * Enable auth_lti plugin.
 357       */
 358      protected function enable_auth() {
 359          $class = \core_plugin_manager::resolve_plugininfo_class('auth');
 360          $class::enable_plugin('lti', true);
 361      }
 362  
 363      /**
 364       * Enable enrol_lti plugin.
 365       */
 366      protected function enable_enrol() {
 367          $class = \core_plugin_manager::resolve_plugininfo_class('enrol');
 368          $class::enable_plugin('lti', true);
 369      }
 370  
 371      /**
 372       * Helper to get a tool_launch_service instance.
 373       *
 374       * @return tool_launch_service the instance.
 375       */
 376      protected function get_tool_launch_service(): tool_launch_service {
 377          return new tool_launch_service(
 378              new deployment_repository(),
 379              new application_registration_repository(),
 380              new resource_link_repository(),
 381              new user_repository(),
 382              new context_repository()
 383          );
 384      }
 385  
 386      /**
 387       * Set up data representing a several published legacy tools, including tool records, tool consumer maps and a user.
 388       *
 389       * @param stdClass $course the course in which to create the tools.
 390       * @param array $legacydata array containing user id, consumer key and tool secrets for creation of records.
 391       * @return array array containing [tool1record, tool2record, consumerrecord, userrecord].
 392       */
 393      protected function setup_legacy_data(\stdClass $course, array $legacydata): array {
 394          // Legacy data: create a consumer record.
 395          global $DB;
 396          $generator = $this->getDataGenerator();
 397          $now = time();
 398          $consumerrecord = (object) [
 399              'name' => 'consumer name',
 400              'consumerkey256' => $legacydata['consumer_key'],
 401              'secret' => '0987654321fff',
 402              'protected' => true,
 403              'enabled' => true,
 404              'created' => $now,
 405              'updated' => $now,
 406          ];
 407          $consumerrecord->id = $DB->insert_record('enrol_lti_lti2_consumer', $consumerrecord);
 408  
 409          // Legacy data: create some modules and publish them as tools, using different secrets, over LTI 1.1.
 410          $tools = [];
 411          $toolconsumermaprecords = [];
 412          foreach ($legacydata['tools'] as $tool) {
 413              $mod = $generator->create_module('assign', ['course' => $course->id]);
 414              $tooldata = [
 415                  'cmid' => $mod->cmid,
 416                  'courseid' => $course->id,
 417                  'membersyncmode' => helper::MEMBER_SYNC_ENROL_AND_UNENROL,
 418                  'membersync' => false,
 419                  'ltiversion' => 'LTI-1p0/LTI-2p0',
 420                  'secret' => $tool['secret']
 421              ];
 422              $legacytool = $generator->create_lti_tool((object)$tooldata);
 423              $tools[] = $legacytool;
 424              $toolconsumermaprecords[] = ['toolid' => $legacytool->id, 'consumerid' => $consumerrecord->id];
 425          }
 426  
 427          // Legacy data: create the tool consumer map, which is created during launches.
 428          $DB->insert_records('enrol_lti_tool_consumer_map', $toolconsumermaprecords);
 429  
 430          // Legacy data: create the user who launched the tools over LTI 1.1.
 431          if (!empty($legacydata['users'])) {
 432              $legacyusers = [];
 433              foreach ($legacydata['users'] as $legacyuser) {
 434                  $legacyusers[] = $generator->create_user([
 435                      'username' => helper::create_username($consumerrecord->consumerkey256, $legacyuser['user_id']),
 436                      'auth' => 'lti',
 437                  ]);
 438              }
 439          }
 440  
 441          return [$tools, $consumerrecord, $legacyusers ?? null];
 442      }
 443  }