Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.
   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 enrol_lti\local\ltiadvantage\repository;
  18  use enrol_lti\local\ltiadvantage\entity\application_registration;
  19  use enrol_lti\local\ltiadvantage\entity\user;
  20  
  21  /**
  22   * Tests for user_repository objects.
  23   *
  24   * @package enrol_lti
  25   * @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
  26   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  27   * @coversDefaultClass \enrol_lti\local\ltiadvantage\repository\user_repository
  28   */
  29  class user_repository_test extends \advanced_testcase {
  30      /**
  31       * Helper to generate a new user instance.
  32       *
  33       * @param int $mockresourceid used to spoof a published resource, to which this user is associated.
  34       * @param array $userfields user information like city, timezone which would normally come from the tool configuration.
  35       * @return user a user instance
  36       */
  37      protected function generate_user(int $mockresourceid = 1, array $userfields = []): user {
  38          global $CFG;
  39          $registration = application_registration::create(
  40              'Test',
  41              'a2c94a2c94',
  42              new \moodle_url('http://lms.example.org'),
  43              'clientid_123',
  44              new \moodle_url('https://example.org/authrequesturl'),
  45              new \moodle_url('https://example.org/jwksurl'),
  46              new \moodle_url('https://example.org/accesstokenurl')
  47          );
  48          $registrationrepo = new application_registration_repository();
  49          $createdregistration = $registrationrepo->save($registration);
  50  
  51          $deployment = $createdregistration->add_tool_deployment('Deployment 1', 'DeployID123');
  52          $deploymentrepo = new deployment_repository();
  53          $saveddeployment = $deploymentrepo->save($deployment);
  54  
  55          $contextrepo = new context_repository();
  56          $context = $saveddeployment->add_context(
  57              'CTX123',
  58              ['http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection']
  59          );
  60          $savedcontext = $contextrepo->save($context);
  61  
  62          $resourcelinkrepo = new resource_link_repository();
  63          $resourcelink = $saveddeployment->add_resource_link('resourcelinkid_123', $mockresourceid,
  64              $savedcontext->get_id());
  65          $savedresourcelink = $resourcelinkrepo->save($resourcelink);
  66  
  67          // Create a user using the DB defaults to simulate what would have occurred during an auth_lti user auth.
  68          $user = $this->getDataGenerator()->create_user([
  69              'city' => '',
  70              'country' => '',
  71              'institution' => '',
  72              'timezone' => '99',
  73              'maildisplay' => 2,
  74              'lang' => 'en'
  75          ]);
  76  
  77          $userdefaultvalues = [
  78              'lang' => $CFG->lang,
  79              'city' => '',
  80              'country' => '',
  81              'institution' => '',
  82              'timezone' => '99',
  83              'maildisplay' => 2
  84          ];
  85          if (empty($userfields)) {
  86              // If userfields is omitted, assume the tool default configuration values (as if 'User default values' are unchanged).
  87              $userfields = $userdefaultvalues;
  88          } else {
  89              // If they have been provided, merge and override the defaults.
  90              $userfields = array_merge($userdefaultvalues, $userfields);
  91          }
  92          $ltiuser = $savedresourcelink->add_user(
  93              $user->id,
  94              'source-id-123',
  95              ...array_values($userfields)
  96          );
  97  
  98          $ltiuser->set_lastgrade(67.33333333);
  99  
 100          return $ltiuser;
 101      }
 102  
 103      /**
 104       * Helper to assert that all the key elements of two users (i.e. excluding id) are equal.
 105       *
 106       * @param user $expected the user whose values are deemed correct.
 107       * @param user $check the user to check.
 108       * @param bool $checkresourcelink whether or not to confirm the resource link value matches too.
 109       */
 110      protected function assert_same_user_values(user $expected, user $check, bool $checkresourcelink = false): void {
 111          $this->assertEquals($expected->get_deploymentid(), $check->get_deploymentid());
 112          $this->assertEquals($expected->get_city(), $check->get_city());
 113          $this->assertEquals($expected->get_country(), $check->get_country());
 114          $this->assertEquals($expected->get_institution(), $check->get_institution());
 115          $this->assertEquals($expected->get_timezone(), $check->get_timezone());
 116          $this->assertEquals($expected->get_maildisplay(), $check->get_maildisplay());
 117          $this->assertEquals($expected->get_lang(), $check->get_lang());
 118          if ($checkresourcelink) {
 119              $this->assertEquals($expected->get_resourcelinkid(), $check->get_resourcelinkid());
 120          }
 121      }
 122  
 123      /**
 124       * Helper to assert that all the key elements of a user are present in the DB.
 125       *
 126       * @param user $expected the user whose values are deemed correct.
 127       */
 128      protected function assert_user_db_values(user $expected) {
 129          global $DB;
 130          $sql = "SELECT u.username, u.firstname, u.lastname, u.email, u.city, u.country, u.institution, u.timezone,
 131                         u.maildisplay, u.mnethostid, u.confirmed, u.lang, u.auth
 132                    FROM {enrol_lti_users} lu
 133                    JOIN {user} u
 134                      ON (lu.userid = u.id)
 135                   WHERE lu.id = :id";
 136          $userrecord = $DB->get_record_sql($sql, ['id' => $expected->get_id()]);
 137          $this->assertEquals($expected->get_city(), $userrecord->city);
 138          $this->assertEquals($expected->get_country(), $userrecord->country);
 139          $this->assertEquals($expected->get_institution(), $userrecord->institution);
 140          $this->assertEquals($expected->get_timezone(), $userrecord->timezone);
 141          $this->assertEquals($expected->get_maildisplay(), $userrecord->maildisplay);
 142          $this->assertEquals($expected->get_lang(), $userrecord->lang);
 143  
 144          $ltiuserrecord = $DB->get_record('enrol_lti_users', ['id' => $expected->get_id()]);
 145          $this->assertEquals($expected->get_id(), $ltiuserrecord->id);
 146          $this->assertEquals($expected->get_sourceid(), $ltiuserrecord->sourceid);
 147          $this->assertEquals($expected->get_resourceid(), $ltiuserrecord->toolid);
 148          $this->assertEquals($expected->get_lastgrade(), $ltiuserrecord->lastgrade);
 149  
 150          if ($expected->get_resourcelinkid()) {
 151              $sql = "SELECT rl.id
 152                        FROM {enrol_lti_users} lu
 153                        JOIN {enrol_lti_user_resource_link} rlj
 154                          ON (lu.id = rlj.ltiuserid)
 155                        JOIN {enrol_lti_resource_link} rl
 156                          ON (rl.id = rlj.resourcelinkid)
 157                       WHERE lu.id = :id";
 158              $resourcelinkrecord = $DB->get_record_sql($sql, ['id' => $expected->get_id()]);
 159              $this->assertEquals($expected->get_resourcelinkid(), $resourcelinkrecord->id);
 160          }
 161      }
 162  
 163      /**
 164       * Tests adding a user to the store, assuming that the user has been created using the default 'user default values'.
 165       *
 166       * @covers ::save
 167       */
 168      public function test_save_new_unchanged_user_defaults() {
 169          $this->resetAfterTest();
 170          $user = $this->generate_user();
 171          $userrepo = new user_repository();
 172          $sink = $this->redirectEvents();
 173          $saveduser = $userrepo->save($user);
 174          $events = $sink->get_events();
 175          $sink->close();
 176  
 177          $this->assertIsInt($saveduser->get_id());
 178          $this->assert_same_user_values($user, $saveduser, true);
 179          $this->assert_user_db_values($saveduser);
 180          // No change to underlying user: city, etc. take on default values matching those of the existing user record.
 181          $this->assertEmpty($events);
 182      }
 183  
 184      /**
 185       * Tests adding a user to the store, assuming that the user has been created using modified 'user default values'.
 186       *
 187       * @covers ::save
 188       */
 189      public function test_save_new_changed_user_defaults() {
 190          $this->resetAfterTest();
 191          $user = $this->generate_user(1, ['city' => 'Perth']);
 192          $userrepo = new user_repository();
 193          $sink = $this->redirectEvents();
 194          $saveduser = $userrepo->save($user);
 195          $events = $sink->get_events();
 196          $sink->close();
 197  
 198          $this->assertIsInt($saveduser->get_id());
 199          $this->assert_same_user_values($user, $saveduser, true);
 200          $this->assert_user_db_values($saveduser);
 201          // The underlying user record will change: city ('Perth') differs from that of the existing user ('').
 202          $this->assertInstanceOf(\core\event\user_updated::class, $events[0]);
 203      }
 204  
 205      /**
 206       * Test saving an existing user instance.
 207       *
 208       * @covers ::save
 209       */
 210      public function test_save_existing() {
 211          $this->resetAfterTest();
 212          $user = $this->generate_user();
 213          $userrepo = new user_repository();
 214          $sink = $this->redirectEvents();
 215          $saveduser = $userrepo->save($user);
 216          $events = $sink->get_events();
 217          $sink->close();
 218          $this->assertEmpty($events); // No event for the first save, since the underlying user record is unchanged.
 219  
 220          $saveduser->set_city('New City');
 221          $saveduser->set_country('NZ');
 222          $saveduser->set_lastgrade(99.99999999);
 223          $sink = $this->redirectEvents();
 224          $saveduser2 = $userrepo->save($saveduser);
 225          $events = $sink->get_events();
 226          $sink->close();
 227  
 228          $this->assertEquals($saveduser->get_id(), $saveduser2->get_id());
 229          $this->assert_same_user_values($saveduser, $saveduser2, true);
 230          $this->assert_user_db_values($saveduser2);
 231          // The underlying user record will change now, since city and country have changed.
 232          $this->assertInstanceOf(\core\event\user_updated::class, $events[0]);
 233      }
 234  
 235      /**
 236       * Test saving an instance which exists by id, but has a different localid to the data in the store.
 237       *
 238       * @covers ::save
 239       */
 240      public function test_save_existing_localid_mismatch() {
 241          $this->resetAfterTest();
 242          $user = $this->generate_user();
 243          $userrepo = new user_repository();
 244          $saveduser = $userrepo->save($user);
 245  
 246          $user2 = user::create(
 247              $saveduser->get_resourceid(),
 248              999999,
 249              $saveduser->get_deploymentid(),
 250              $saveduser->get_sourceid(),
 251              $saveduser->get_lang(),
 252              $saveduser->get_timezone(),
 253              '',
 254              '',
 255              '',
 256              null,
 257              null,
 258              null,
 259              null,
 260              $saveduser->get_id()
 261          );
 262          $this->expectException(\coding_exception::class);
 263          $this->expectExceptionMessage("Cannot update user mapping. LTI user '{$saveduser->get_id()}' is already mapped " .
 264              "to user '{$saveduser->get_localid()}' and can't be associated with another user '999999'.");
 265          $userrepo->save($user2);
 266      }
 267  
 268      /**
 269       * Test trying to save a user with an id that is invalid.
 270       *
 271       * @covers ::save
 272       */
 273      public function test_save_stale_id() {
 274          global $CFG;
 275          $this->resetAfterTest();
 276          $instructoruser = $this->getDataGenerator()->create_user();
 277          $userrepo = new user_repository();
 278          $user = user::create(
 279              4,
 280              $instructoruser->id,
 281              5,
 282              'source-id-123',
 283              $CFG->lang,
 284              '99',
 285              '',
 286              '',
 287              '',
 288              null,
 289              null,
 290              null,
 291              null,
 292              999999
 293          );
 294  
 295          $this->expectException(\coding_exception::class);
 296          $this->expectExceptionMessage("Cannot save lti user with id '999999'. The record does not exist.");
 297          $userrepo->save($user);
 298      }
 299  
 300      /**
 301       * Verify that trying to save a stale object results in an exception referring to unique constraint violation.
 302       *
 303       * @covers ::save
 304       */
 305      public function test_save_uniqueness_constraint() {
 306          $this->resetAfterTest();
 307          $user = $this->generate_user();
 308          $userrepo = new user_repository();
 309          $userrepo->save($user);
 310  
 311          $this->expectException(\coding_exception::class);
 312          $this->expectExceptionMessageMatches("/Cannot create duplicate LTI user '[a-z0-9_]*' for resource '[0-9]*'/");
 313          $userrepo->save($user);
 314      }
 315  
 316      /**
 317       * Test finding a user instance by id.
 318       *
 319       * @covers ::find
 320       */
 321      public function test_find() {
 322          $this->resetAfterTest();
 323          $user = $this->generate_user();
 324          $userrepo = new user_repository();
 325          $saveduser = $userrepo->save($user);
 326  
 327          $founduser = $userrepo->find($saveduser->get_id());
 328          $this->assertIsInt($founduser->get_id());
 329          $this->assert_same_user_values($saveduser, $founduser, false);
 330  
 331          $this->assertNull($userrepo->find(0));
 332      }
 333  
 334      /**
 335       * Test finding all of users associated with a given published resource.
 336       *
 337       * @covers ::find_by_resource
 338       */
 339      public function test_find_by_resource() {
 340          global $CFG;
 341          $this->resetAfterTest();
 342          $user = $this->generate_user();
 343          $userrepo = new user_repository();
 344          $saveduser = $userrepo->save($user);
 345          $instructoruser = $this->getDataGenerator()->create_user();
 346  
 347          $user2 = user::create(
 348              $saveduser->get_resourceid(),
 349              $instructoruser->id,
 350              $saveduser->get_deploymentid(),
 351              'another-user-123',
 352              $CFG->lang,
 353              '99',
 354              'Perth',
 355              'AU',
 356              'An Example Institution',
 357              2
 358          );
 359          $saveduser2 = $userrepo->save($user2);
 360          $savedusers = [$saveduser->get_id() => $saveduser, $saveduser2->get_id() => $saveduser2];
 361  
 362          $foundusers = $userrepo->find_by_resource($saveduser->get_resourceid());
 363          $this->assertCount(2, $foundusers);
 364          foreach ($foundusers as $founduser) {
 365              $this->assert_same_user_values($savedusers[$founduser->get_id()], $founduser);
 366          }
 367      }
 368  
 369      /**
 370       * Test that users can be found based on their resource_link association.
 371       *
 372       * @covers ::find_by_resource_link
 373       */
 374      public function test_find_by_resource_link() {
 375          global $CFG;
 376          $this->resetAfterTest();
 377          $user = $this->generate_user();
 378          $user->set_resourcelinkid(33);
 379          $userrepo = new user_repository();
 380          $saveduser = $userrepo->save($user);
 381  
 382          $instructoruser = $this->getDataGenerator()->create_user();
 383          $user2 = user::create(
 384              $saveduser->get_resourceid(),
 385              $instructoruser->id,
 386              $saveduser->get_deploymentid(),
 387              'another-user-123',
 388              $CFG->lang,
 389              '99',
 390              'Perth',
 391              'AU',
 392              'An Example Institution',
 393              2,
 394              null,
 395              null,
 396              33
 397          );
 398          $saveduser2 = $userrepo->save($user2);
 399          $savedusers = [$saveduser->get_id() => $saveduser, $saveduser2->get_id() => $saveduser2];
 400  
 401          $foundusers = $userrepo->find_by_resource_link(33);
 402          $this->assertCount(2, $foundusers);
 403          foreach ($foundusers as $founduser) {
 404              $this->assert_same_user_values($savedusers[$founduser->get_id()], $founduser);
 405          }
 406      }
 407  
 408      /**
 409       * Test checking existence of a user instance, based on id.
 410       *
 411       * @covers ::exists
 412       */
 413      public function test_exists() {
 414          $this->resetAfterTest();
 415          $user = $this->generate_user();
 416          $userrepo = new user_repository();
 417          $saveduser = $userrepo->save($user);
 418  
 419          $this->assertTrue($userrepo->exists($saveduser->get_id()));
 420          $this->assertFalse($userrepo->exists(-50));
 421      }
 422  
 423      /**
 424       * Test deleting a user instance, based on id.
 425       *
 426       * @covers ::delete
 427       */
 428      public function test_delete() {
 429          $this->resetAfterTest();
 430          $user = $this->generate_user();
 431          $userrepo = new user_repository();
 432          $saveduser = $userrepo->save($user);
 433          $this->assertTrue($userrepo->exists($saveduser->get_id()));
 434  
 435          $userrepo->delete($saveduser->get_id());
 436          $this->assertFalse($userrepo->exists($saveduser->get_id()));
 437  
 438          global $DB;
 439          $this->assertFalse($DB->record_exists('enrol_lti_users', ['id' => $saveduser->get_id()]));
 440          $this->assertFalse($DB->record_exists('enrol_lti_user_resource_link', ['ltiuserid' => $saveduser->get_id()]));
 441          $this->assertTrue($DB->record_exists('user', ['id' => $saveduser->get_localid()]));
 442  
 443          $this->assertNull($userrepo->delete($saveduser->get_id()));
 444      }
 445  
 446      /**
 447       * Test deleting a collection of lti user instances by deployment.
 448       *
 449       * @covers ::delete_by_deployment
 450       */
 451      public function test_delete_by_deployment() {
 452          global $CFG;
 453          $this->resetAfterTest();
 454          $user = $this->generate_user();
 455          $userrepo = new user_repository();
 456          $saveduser = $userrepo->save($user);
 457          $instructoruser = $this->getDataGenerator()->create_user();
 458          $instructor2user = $this->getDataGenerator()->create_user();
 459  
 460          $user2 = user::create(
 461              $saveduser->get_resourceid(),
 462              $instructoruser->id,
 463              $saveduser->get_deploymentid(),
 464              'another-user-123',
 465              $CFG->lang,
 466              '99',
 467              'Perth',
 468              'AU',
 469              'An Example Institution',
 470          );
 471          $saveduser2 = $userrepo->save($user2);
 472  
 473          $user3 = user::create(
 474              $saveduser->get_resourceid(),
 475              $instructor2user->id,
 476              $saveduser->get_deploymentid() + 1,
 477              'another-user-678',
 478              $CFG->lang,
 479              '99',
 480              'Melbourne',
 481              'AU',
 482              'An Example Institution',
 483          );
 484          $saveduser3 = $userrepo->save($user3);
 485          $this->assertTrue($userrepo->exists($saveduser->get_id()));
 486          $this->assertTrue($userrepo->exists($saveduser2->get_id()));
 487          $this->assertTrue($userrepo->exists($saveduser3->get_id()));
 488  
 489          $userrepo->delete_by_deployment($saveduser->get_deploymentid());
 490          $this->assertFalse($userrepo->exists($saveduser->get_id()));
 491          $this->assertFalse($userrepo->exists($saveduser2->get_id()));
 492          $this->assertTrue($userrepo->exists($saveduser3->get_id()));
 493      }
 494  
 495      /**
 496       * Verify a user who has been deleted can be re-saved to the repository and matched to an existing local user.
 497       *
 498       * @covers ::save
 499       */
 500      public function test_save_deleted() {
 501          $this->resetAfterTest();
 502          $user = $this->generate_user();
 503          $userrepo = new user_repository();
 504          $saveduser = $userrepo->save($user);
 505  
 506          $userrepo->delete($saveduser->get_id());
 507          $this->assertFalse($userrepo->exists($saveduser->get_id()));
 508  
 509          $saveduser2 = $userrepo->save($user);
 510          $this->assertEquals($saveduser->get_localid(), $saveduser2->get_localid());
 511          $this->assertNotEquals($saveduser->get_id(), $saveduser2->get_id());
 512      }
 513  
 514      /**
 515       * Test confirming that any associated legacy lti user records are not returned by the repository.
 516       *
 517       * This test ensures that any enrolment methods (resources) updated in-place from legacy LTI to 1.3 only return LTI 1.3 users.
 518       *
 519       * @covers ::find
 520       * @covers ::find_single_user_by_resource
 521       * @covers ::find_by_resource
 522       */
 523      public function test_find_filters_legacy_lti_users(): void {
 524          $this->resetAfterTest();
 525          global $DB;
 526          $user = $this->getDataGenerator()->create_user();
 527          $course = $this->getDataGenerator()->create_course();
 528          $resource = $this->getDataGenerator()->create_lti_tool((object)['courseid' => $course->id]);
 529          $ltiuserdata = [
 530              'userid' => $user->id,
 531              'toolid' => $resource->id,
 532              'sourceid' => '1001',
 533          ];
 534          $ltiuserid = $DB->insert_record('enrol_lti_users', $ltiuserdata);
 535          $userrepo = new user_repository();
 536  
 537          $this->assertNull($userrepo->find($ltiuserid));
 538          $this->assertNull($userrepo->find_single_user_by_resource($user->id, $resource->id));
 539          $this->assertEmpty($userrepo->find_by_resource($resource->id));
 540  
 541          // Set deploymentid, indicating the user originated from an LTI 1.3 launch and should now be returned.
 542          $ltiuserdata['id'] = $ltiuserid;
 543          $ltiuserdata['ltideploymentid'] = '234';
 544          $DB->update_record('enrol_lti_users', $ltiuserdata);
 545  
 546          $this->assertInstanceOf(user::class, $userrepo->find($ltiuserid));
 547          $this->assertInstanceOf(user::class, $userrepo->find_single_user_by_resource($user->id, $resource->id));
 548          $ltiusers = $userrepo->find_by_resource($resource->id);
 549          $this->assertCount(1, $ltiusers);
 550          $this->assertInstanceOf(user::class, reset($ltiusers));
 551      }
 552  }