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 400 and 403] [Versions 401 and 403] [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 auth_lti;
  18  
  19  /**
  20   * Tests for the auth_plugin_lti class.
  21   *
  22   * @package    auth_lti
  23   * @copyright  2021 Jake Dallimore <jrhdallimore@gmail.com>
  24   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  25   * @coversDefaultClass \auth_plugin_lti
  26   */
  27  class auth_test extends \advanced_testcase {
  28  
  29      /** @var string issuer URL used for test cases. */
  30      protected $issuer = 'https://lms.example.org';
  31  
  32      /** @var int const representing cases where no PII is present. */
  33      protected const PII_NONE = 0;
  34  
  35      /** @var int const representing cases where only names are included in PII. */
  36      protected const PII_NAMES_ONLY = 1;
  37  
  38      /** @var int const representing cases where only email is included in PII. */
  39      protected const PII_EMAILS_ONLY = 2;
  40  
  41      /** @var int const representing cases where both names and email are included in PII. */
  42      protected const PII_ALL = 3;
  43  
  44      /**
  45       * Verify the user's profile picture has been set, which is useful to verify picture syncs.
  46       *
  47       * @param int $userid the id of the Moodle user.
  48       */
  49      protected function verify_user_profile_image_updated(int $userid): void {
  50          global $CFG;
  51          $user = \core_user::get_user($userid);
  52          $usercontext = \context_user::instance($user->id);
  53          $expected = $CFG->wwwroot . '/pluginfile.php/' . $usercontext->id . '/user/icon/boost/f2?rev='. $user->picture;
  54  
  55          $page = new \moodle_page();
  56          $page->set_url('/user/profile.php');
  57          $page->set_context(\context_system::instance());
  58          $renderer = $page->get_renderer('core');
  59          $userpicture = new \user_picture($user);
  60          $this->assertEquals($expected, $userpicture->get_url($page, $renderer)->out(false));
  61      }
  62  
  63      /**
  64       * Get a list of users ready for use with mock authentication requests by providing an array of user ids.
  65       *
  66       * @param array $ids the platform user_ids for the users.
  67       * @param string $role the LTI role to include in the user data.
  68       * @param bool $includenames whether to include the firstname and lastname of the user
  69       * @param bool $includeemail whether to include the email of the user
  70       * @param bool $includepicture whether to include a profile picture or not (slows tests, so defaults to false).
  71       * @return array the users list.
  72       */
  73      protected function get_mock_users_with_ids(array $ids,
  74              string $role = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', bool $includenames = true,
  75              bool $includeemail = true, bool $includepicture = false): array {
  76  
  77          $users = [];
  78          foreach ($ids as $id) {
  79              $user = [
  80                  'user_id' => $id,
  81                  'given_name' => 'Firstname' . $id,
  82                  'family_name' => 'Surname' . $id,
  83                  'email' => "firstname.surname{$id}@lms.example.org",
  84                  'roles' => [$role]
  85              ];
  86              if (!$includenames) {
  87                  unset($user['given_name']);
  88                  unset($user['family_name']);
  89              }
  90              if (!$includeemail) {
  91                  unset($user['email']);
  92              }
  93              if ($includepicture) {
  94                  $user['picture'] = $this->getExternalTestFileUrl('/test.jpg');
  95              }
  96              $users[] = $user;
  97          }
  98          return $users;
  99      }
 100  
 101      /**
 102       * Get a mock member structure based on a mock user and, optionally, a legacy user id.
 103       *
 104       * @param array $mockuser the user data
 105       * @param string $legacyuserid the id of the user in the platform in 1.1, if different from the id used in 1.3.
 106       * @return array
 107       */
 108      protected function get_mock_member_data_for_user(array $mockuser, string $legacyuserid = ''): array {
 109          $data = [
 110              'user_id' => $mockuser['user_id'],
 111              'roles' => $mockuser['roles']
 112          ];
 113          if (isset($mockuser['given_name'])) {
 114              $data['given_name'] = $mockuser['given_name'];
 115          }
 116          if (isset($mockuser['family_name'])) {
 117              $data['family_name'] = $mockuser['family_name'];
 118          }
 119          if (isset($mockuser['email'])) {
 120              $data['email'] = $mockuser['email'];
 121          }
 122          if (!empty($mockuser['picture'])) {
 123              $data['picture'] = $mockuser['picture'];
 124          }
 125          if (!empty($legacyuserid)) {
 126              $data['lti11_legacy_user_id'] = $legacyuserid;
 127          }
 128          return $data;
 129      }
 130  
 131      /**
 132       * Get mocked JWT data for the given user, including optionally the migration claim information if provided.
 133       *
 134       * @param array $mockuser the user data
 135       * @param array $mockmigration information needed to mock the migration claim
 136       * @return array the mock JWT data
 137       */
 138      protected function get_mock_launchdata_for_user(array $mockuser, array $mockmigration = []): array {
 139          $data = [
 140              'iss' => $this->issuer, // Must match registration in create_test_environment.
 141              'aud' => '123', // Must match registration in create_test_environment.
 142              'sub' => $mockuser['user_id'], // User id on the platform site.
 143              'exp' => time() + 60,
 144              'nonce' => 'some-nonce-value-123',
 145              'https://purl.imsglobal.org/spec/lti/claim/deployment_id' => '1', // Must match registration.
 146              'https://purl.imsglobal.org/spec/lti/claim/roles' => $mockuser['roles'],
 147              'https://purl.imsglobal.org/spec/lti/claim/resource_link' => [
 148                  'title' => "Res link title",
 149                  'id' => 'res-link-id-123',
 150              ],
 151              "https://purl.imsglobal.org/spec/lti/claim/context" => [
 152                  "id" => "context-id-12345",
 153                  "label" => "ITS 123",
 154                  "title" => "ITS 123 Machine Learning",
 155                  "type" => ["http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering"]
 156              ],
 157              'https://purl.imsglobal.org/spec/lti/claim/target_link_uri' =>
 158                  'https://this-moodle-tool.example.org/context/24/resource/14',
 159              'https://purl.imsglobal.org/spec/lti/claim/custom' => [
 160                  'id' => '1'
 161              ]
 162          ];
 163  
 164          if (isset($mockuser['given_name'])) {
 165              $data['given_name'] = $mockuser['given_name'];
 166          }
 167          if (isset($mockuser['family_name'])) {
 168              $data['family_name'] = $mockuser['family_name'];
 169          }
 170          if (isset($mockuser['email'])) {
 171              $data['email'] = $mockuser['email'];
 172          }
 173  
 174          if (!empty($mockuser['picture'])) {
 175              $data['picture'] = $mockuser['picture'];
 176          }
 177  
 178          if ($mockmigration) {
 179              if (isset($mockmigration['consumer_key'])) {
 180                  $base = [
 181                      $mockmigration['consumer_key'],
 182                      $data['https://purl.imsglobal.org/spec/lti/claim/deployment_id'],
 183                      $data['iss'],
 184                      $data['aud'],
 185                      $data['exp'],
 186                      $data['nonce']
 187                  ];
 188                  $basestring = implode('&', $base);
 189  
 190                  $data['https://purl.imsglobal.org/spec/lti/claim/lti1p1'] = [
 191                      'oauth_consumer_key' => $mockmigration['consumer_key'],
 192                  ];
 193  
 194                  if (isset($mockmigration['signing_secret'])) {
 195                      $sig = base64_encode(hash_hmac('sha256', $basestring, $mockmigration['signing_secret']));
 196                      $data['https://purl.imsglobal.org/spec/lti/claim/lti1p1']['oauth_consumer_key_sign'] = $sig;
 197                  }
 198              }
 199  
 200              if (isset($mockmigration['user_id'])) {
 201                  $data['https://purl.imsglobal.org/spec/lti/claim/lti1p1']['user_id'] =
 202                      $mockmigration['user_id'];
 203              }
 204          }
 205          return $data;
 206      }
 207  
 208      /**
 209       * Test which verifies a user account can be created/found using the find_or_create_user_from_launch() method.
 210       *
 211       * @dataProvider launch_data_provider
 212       * @param array|null $legacydata legacy user and tool data, if testing migration cases.
 213       * @param array $launchdata data describing the launch, including user data and migration claim data.
 214       * @param array $expected the test case expectations.
 215       * @covers ::find_or_create_user_from_launch
 216       */
 217      public function test_find_or_create_user_from_launch(?array $legacydata, array $launchdata, array $expected) {
 218          $this->resetAfterTest();
 219          global $DB;
 220          $auth = get_auth_plugin('lti');
 221  
 222          // When testing platform users who have authenticated before, make that first auth call.
 223          if (!empty($launchdata['has_authenticated_before'])) {
 224              $mockjwtdata = $this->get_mock_launchdata_for_user($launchdata['user']);
 225              $firstauthuser = $auth->find_or_create_user_from_launch($mockjwtdata);
 226          }
 227  
 228          // Create legacy users and mocked tool secrets if desired.
 229          $legacysecrets = [];
 230          if ($legacydata) {
 231              $legacyusers = [];
 232              $generator = $this->getDataGenerator();
 233              foreach ($legacydata['users'] as $legacyuser) {
 234                  $username = 'enrol_lti' . sha1($legacydata['consumer_key'] . '::' . $legacydata['consumer_key'] .
 235                          ':' . $legacyuser['user_id']);
 236  
 237                  $legacyusers[] = $generator->create_user([
 238                      'username' => $username,
 239                      'auth' => 'lti'
 240                  ]);
 241              }
 242              // In a real usage, legacy tool secrets are only passed for a consumer, as indicated in the migration claim.
 243              if (!empty($launchdata['migration_claim'])) {
 244                  $legacysecrets = array_column($legacydata['tools'], 'secret');
 245              }
 246          }
 247  
 248          // Mock the launchdata.
 249          $mockjwtdata = $this->get_mock_launchdata_for_user($launchdata['user'], $launchdata['migration_claim'] ?? []);
 250  
 251          // Authenticate the platform user.
 252          $sink = $this->redirectEvents();
 253          $countusersbefore = $DB->count_records('user');
 254          $user = $auth->find_or_create_user_from_launch($mockjwtdata, true, $legacysecrets);
 255          if (!empty($expected['migration_debugging'])) {
 256              $this->assertDebuggingCalled();
 257          }
 258          $countusersafter = $DB->count_records('user');
 259          $events = $sink->get_events();
 260          $sink->close();
 261  
 262          // Verify user count is correct. i.e. no user is created when migration claim is correctly processed or when
 263          // the user has authenticated with the tool before.
 264          $numnewusers = (!empty($expected['migrated'])) ? 0 : 1;
 265          $numnewusers = (!empty($launchdata['has_authenticated_before'])) ?
 266              0 : $numnewusers;
 267          $this->assertEquals($numnewusers, $countusersafter - $countusersbefore);
 268  
 269          // Verify PII is updated appropriately.
 270          switch ($expected['PII']) {
 271              case self::PII_ALL:
 272                  $this->assertEquals($launchdata['user']['given_name'], $user->firstname);
 273                  $this->assertEquals($launchdata['user']['family_name'], $user->lastname);
 274                  $this->assertEquals($launchdata['user']['email'], $user->email);
 275                  break;
 276              case self::PII_NAMES_ONLY:
 277                  $this->assertEquals($launchdata['user']['given_name'], $user->firstname);
 278                  $this->assertEquals($launchdata['user']['family_name'], $user->lastname);
 279                  $email = 'enrol_lti_13_' . sha1($mockjwtdata['iss'] . '_' . $mockjwtdata['sub']) . "@example.com";
 280                  $this->assertEquals($email, $user->email);
 281                  break;
 282              case self::PII_EMAILS_ONLY:
 283                  $this->assertEquals($mockjwtdata['iss'], $user->lastname);
 284                  $this->assertEquals($mockjwtdata['sub'], $user->firstname);
 285                  $this->assertEquals($launchdata['user']['email'], $user->email);
 286                  break;
 287              default:
 288              case self::PII_NONE:
 289                  $this->assertEquals($mockjwtdata['iss'], $user->lastname);
 290                  $this->assertEquals($mockjwtdata['sub'], $user->firstname);
 291                  $email = 'enrol_lti_13_' . sha1($mockjwtdata['iss'] . '_' . $mockjwtdata['sub']) . "@example.com";
 292                  $this->assertEquals($email, $user->email);
 293                  break;
 294          }
 295  
 296          // Verify picture sync occurs, if expected.
 297          if (!empty($expected['syncpicture'])) {
 298              $this->verify_user_profile_image_updated($user->id);
 299          }
 300  
 301          if (!empty($expected['migrated'])) {
 302              // If migrated, verify the user account is reusing the legacy user account.
 303              $legacyuserids = array_column($legacyusers, 'id');
 304              $this->assertContains($user->id, $legacyuserids);
 305              $this->assertInstanceOf(\core\event\user_updated::class, $events[0]);
 306          } else if (isset($firstauthuser)) {
 307              // If the user is authenticating a second time, confirm the same account is being returned.
 308              $this->assertEquals($firstauthuser->id, $user->id);
 309              $this->assertEmpty($events); // The user authenticated with the same data once before, so we don't expect an update.
 310          } else {
 311              // The user wasn't migrated and hasn't launched before, so we expect a user_created event.
 312              $this->assertInstanceOf(\core\event\user_created::class, $events[0]);
 313          }
 314      }
 315  
 316      /**
 317       * Data provider for testing launch-based authentication.
 318       *
 319       * @return array the test case data.
 320       */
 321      public function launch_data_provider(): array {
 322          return [
 323              'New (unlinked) platform learner including PII, no legacy user, no migration claim' => [
 324                  'legacy_data' => null,
 325                  'launch_data' => [
 326                      'user' => $this->get_mock_users_with_ids(
 327                          ['1'],
 328                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 329                      )[0],
 330                      'migration_claim' => null
 331                  ],
 332                  'expected' => [
 333                      'PII' => self::PII_ALL,
 334                  ]
 335              ],
 336              'New (unlinked) platform learner excluding names, no legacy user, no migration claim' => [
 337                  'legacy_data' => null,
 338                  'launch_data' => [
 339                      'user' => $this->get_mock_users_with_ids(
 340                          ['1'],
 341                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
 342                          false
 343                      )[0],
 344                      'migration_claim' => null
 345                  ],
 346                  'expected' => [
 347                      'PII' => self::PII_EMAILS_ONLY,
 348                  ]
 349              ],
 350              'New (unlinked) platform learner excluding emails, no legacy user, no migration claim' => [
 351                  'legacy_data' => null,
 352                  'launch_data' => [
 353                      'user' => $this->get_mock_users_with_ids(
 354                          ['1'],
 355                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
 356                          true,
 357                          false
 358                      )[0],
 359                      'migration_claim' => null
 360                  ],
 361                  'expected' => [
 362                      'PII' => self::PII_NAMES_ONLY,
 363                  ]
 364              ],
 365              'New (unlinked) platform learner excluding all PII, no legacy user, no migration claim' => [
 366                  'legacy_data' => null,
 367                  'launch_data' => [
 368                      'user' => $this->get_mock_users_with_ids(
 369                          ['1'],
 370                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
 371                          false,
 372                          false
 373                      )[0],
 374                      'migration_claim' => null
 375                  ],
 376                  'expected' => [
 377                      'PII' => self::PII_NONE,
 378                  ]
 379              ],
 380              'New (unlinked) platform learner including PII, existing legacy user, valid migration claim' => [
 381                  'legacy_data' => [
 382                      'users' => [
 383                          ['user_id' => '123-abc'],
 384                      ],
 385                      'consumer_key' => 'CONSUMER_1',
 386                      'tools' => [
 387                          ['secret' => 'toolsecret1'],
 388                          ['secret' => 'toolsecret2'],
 389                      ]
 390                  ],
 391                  'launch_data' => [
 392                      'user' => $this->get_mock_users_with_ids(
 393                          ['1'],
 394                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 395                      )[0],
 396                      'migration_claim' => [
 397                          'consumer_key' => 'CONSUMER_1',
 398                          'signing_secret' => 'toolsecret1',
 399                          'user_id' => '123-abc',
 400                          'context_id' => 'd345b',
 401                          'tool_consumer_instance_guid' => '12345-123',
 402                          'resource_link_id' => '4b6fa'
 403                      ]
 404                  ],
 405                  'expected' => [
 406                      'PII' => self::PII_ALL,
 407                      'migrated' => true
 408                  ]
 409              ],
 410              'New (unlinked) platform learner including PII, existing legacy user, no migration claim' => [
 411                  'legacy_data' => [
 412                      'users' => [
 413                          ['user_id' => '123-abc'],
 414                      ],
 415                      'consumer_key' => 'CONSUMER_1',
 416                      'tools' => [
 417                          ['secret' => 'toolsecret1'],
 418                          ['secret' => 'toolsecret2'],
 419                      ]
 420                  ],
 421                  'launch_data' => [
 422                      'user' => $this->get_mock_users_with_ids(
 423                          ['1'],
 424                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 425                      )[0],
 426                      'migration_claim' => null,
 427                  ],
 428                  'expected' => [
 429                      'PII' => self::PII_ALL,
 430                      'migrated' => false,
 431                  ]
 432              ],
 433              'New (unlinked) platform learner including PII, existing legacy user, migration missing consumer_key' => [
 434                  'legacy_data' => [
 435                      'users' => [
 436                          ['user_id' => '123-abc'],
 437                      ],
 438                      'consumer_key' => 'CONSUMER_1',
 439                      'tools' => [
 440                          ['secret' => 'toolsecret1'],
 441                          ['secret' => 'toolsecret2'],
 442                      ]
 443                  ],
 444                  'launch_data' => [
 445                      'user' => $this->get_mock_users_with_ids(
 446                          ['1'],
 447                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 448                      )[0],
 449                      'migration_claim' => [
 450                          'signing_secret' => 'toolsecret1',
 451                          'user_id' => '123-abc',
 452                          'context_id' => 'd345b',
 453                          'tool_consumer_instance_guid' => '12345-123',
 454                          'resource_link_id' => '4b6fa'
 455                      ]
 456                  ],
 457                  'expected' => [
 458                      'PII' => self::PII_ALL,
 459                      'migrated' => false,
 460                      'migration_debugging' => true,
 461                  ]
 462              ],
 463              'New (unlinked) platform learner including PII, existing legacy user, migration bad consumer_key' => [
 464                  'legacy_data' => [
 465                      'users' => [
 466                          ['user_id' => '123-abc'],
 467                      ],
 468                      'consumer_key' => 'CONSUMER_1',
 469                      'tools' => [
 470                          ['secret' => 'toolsecret1'],
 471                          ['secret' => 'toolsecret2'],
 472                      ]
 473                  ],
 474                  'launch_data' => [
 475                      'user' => $this->get_mock_users_with_ids(
 476                          ['1'],
 477                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 478                      )[0],
 479                      'migration_claim' => [
 480                          'consumer_key' => 'CONSUMER_BAD',
 481                          'signing_secret' => 'toolsecret1',
 482                          'user_id' => '123-abc',
 483                          'context_id' => 'd345b',
 484                          'tool_consumer_instance_guid' => '12345-123',
 485                          'resource_link_id' => '4b6fa'
 486                      ]
 487                  ],
 488                  'expected' => [
 489                      'PII' => self::PII_ALL,
 490                      'migrated' => false,
 491                  ]
 492              ],
 493              'New (unlinked) platform learner including PII, existing legacy user, migration user not matched' => [
 494                  'legacy_data' => [
 495                      'users' => [
 496                          ['user_id' => '123-abc'],
 497                      ],
 498                      'consumer_key' => 'CONSUMER_1',
 499                      'tools' => [
 500                          ['secret' => 'toolsecret1'],
 501                          ['secret' => 'toolsecret2'],
 502                      ]
 503                  ],
 504                  'launch_data' => [
 505                      'user' => $this->get_mock_users_with_ids(
 506                          ['1'],
 507                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 508                      )[0],
 509                      'migration_claim' => [
 510                          'consumer_key' => 'CONSUMER_1',
 511                          'signing_secret' => 'toolsecret1',
 512                          'user_id' => '234-bcd',
 513                          'context_id' => 'd345b',
 514                          'tool_consumer_instance_guid' => '12345-123',
 515                          'resource_link_id' => '4b6fa'
 516                      ]
 517                  ],
 518                  'expected' => [
 519                      'PII' => self::PII_ALL,
 520                      'migrated' => false
 521                  ]
 522              ],
 523              'New (unlinked) platform learner including PII, existing legacy user, valid migration claim secret2' => [
 524                  'legacy_data' => [
 525                      'users' => [
 526                          ['user_id' => '123-abc'],
 527                      ],
 528                      'consumer_key' => 'CONSUMER_1',
 529                      'tools' => [
 530                          ['secret' => 'toolsecret1'],
 531                          ['secret' => 'toolsecret2'],
 532                      ]
 533                  ],
 534                  'launch_data' => [
 535                      'user' => $this->get_mock_users_with_ids(
 536                          ['1'],
 537                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 538                      )[0],
 539                      'migration_claim' => [
 540                          'consumer_key' => 'CONSUMER_1',
 541                          'signing_secret' => 'toolsecret2',
 542                          'user_id' => '123-abc',
 543                          'context_id' => 'd345b',
 544                          'tool_consumer_instance_guid' => '12345-123',
 545                          'resource_link_id' => '4b6fa'
 546                      ]
 547                  ],
 548                  'expected' => [
 549                      'PII' => self::PII_ALL,
 550                      'migrated' => true
 551                  ]
 552              ],
 553              'New (unlinked) platform learner including PII, existing legacy user, migration claim bad secret' => [
 554                  'legacy_data' => [
 555                      'users' => [
 556                          ['user_id' => '123-abc'],
 557                      ],
 558                      'consumer_key' => 'CONSUMER_1',
 559                      'tools' => [
 560                          ['secret' => 'toolsecret1'],
 561                          ['secret' => 'toolsecret2'],
 562                      ]
 563                  ],
 564                  'launch_data' => [
 565                      'user' => $this->get_mock_users_with_ids(
 566                          ['1'],
 567                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 568                      )[0],
 569                      'migration_claim' => [
 570                          'consumer_key' => 'CONSUMER_1',
 571                          'signing_secret' => 'bad_secret',
 572                          'user_id' => '123-abc',
 573                          'context_id' => 'd345b',
 574                          'tool_consumer_instance_guid' => '12345-123',
 575                          'resource_link_id' => '4b6fa'
 576                      ]
 577                  ],
 578                  'expected' => [
 579                      'PII' => self::PII_ALL,
 580                      'migrated' => false,
 581                      'migration_debugging' => true,
 582                  ]
 583              ],
 584              'New (unlinked) platform learner including PII, no legacy user, valid migration claim' => [
 585                  'legacy_data' => [
 586                      'users' => [],
 587                      'consumer_key' => 'CONSUMER_1',
 588                      'tools' => [
 589                          ['secret' => 'toolsecret1'],
 590                          ['secret' => 'toolsecret2'],
 591                      ]
 592                  ],
 593                  'launch_data' => [
 594                      'user' => $this->get_mock_users_with_ids(
 595                          ['1'],
 596                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 597                      )[0],
 598                      'migration_claim' => [
 599                          'consumer_key' => 'CONSUMER_1',
 600                          'signing_secret' => 'toolsecret2',
 601                          'user_id' => '123-abc',
 602                          'context_id' => 'd345b',
 603                          'tool_consumer_instance_guid' => '12345-123',
 604                          'resource_link_id' => '4b6fa'
 605                      ]
 606                  ],
 607                  'expected' => [
 608                      'PII' => self::PII_ALL,
 609                      'migrated' => false
 610                  ]
 611              ],
 612              'New (unlinked) platform learner excluding PII, existing legacy user, valid migration claim' => [
 613                  'legacy_data' => [
 614                      'users' => [
 615                          ['user_id' => '123-abc'],
 616                      ],
 617                      'consumer_key' => 'CONSUMER_1',
 618                      'tools' => [
 619                          ['secret' => 'toolsecret1'],
 620                          ['secret' => 'toolsecret2'],
 621                      ]
 622                  ],
 623                  'launch_data' => [
 624                      'user' => $this->get_mock_users_with_ids(
 625                          ['1'],
 626                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
 627                          false,
 628                          false
 629                      )[0],
 630                      'migration_claim' => [
 631                          'consumer_key' => 'CONSUMER_1',
 632                          'signing_secret' => 'toolsecret1',
 633                          'user_id' => '123-abc',
 634                          'context_id' => 'd345b',
 635                          'tool_consumer_instance_guid' => '12345-123',
 636                          'resource_link_id' => '4b6fa'
 637                      ]
 638                  ],
 639                  'expected' => [
 640                      'PII' => self::PII_NONE,
 641                      'migrated' => true
 642                  ]
 643              ],
 644              'New (unlinked) platform instructor including PII, no legacy user, no migration claim' => [
 645                  'legacy_data' => null,
 646                  'launch_data' => [
 647                      'user' => $this->get_mock_users_with_ids(
 648                          ['1'],
 649                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'
 650                      )[0],
 651                      'migration_claim' => null
 652                  ],
 653                  'expected' => [
 654                      'PII' => self::PII_ALL,
 655                  ]
 656              ],
 657              'New (unlinked) platform instructor excluding PII, no legacy user, no migration claim' => [
 658                  'legacy_data' => null,
 659                  'launch_data' => [
 660                      'user' => $this->get_mock_users_with_ids(
 661                          ['1'],
 662                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
 663                          false,
 664                          false
 665                      )[0],
 666                      'migration_claim' => null
 667                  ],
 668                  'expected' => [
 669                      'PII' => self::PII_NONE,
 670                  ]
 671              ],
 672              'New (unlinked) platform instructor including PII, existing legacy user, valid migration claim' => [
 673                  'legacy_data' => [
 674                      'users' => [
 675                          ['user_id' => '123-abc'],
 676                      ],
 677                      'consumer_key' => 'CONSUMER_1',
 678                      'tools' => [
 679                          ['secret' => 'toolsecret1'],
 680                          ['secret' => 'toolsecret2'],
 681                      ]
 682                  ],
 683                  'launch_data' => [
 684                      'user' => $this->get_mock_users_with_ids(
 685                          ['1'],
 686                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'
 687                      )[0],
 688                      'migration_claim' => [
 689                          'consumer_key' => 'CONSUMER_1',
 690                          'signing_secret' => 'toolsecret1',
 691                          'user_id' => '123-abc',
 692                          'context_id' => 'd345b',
 693                          'tool_consumer_instance_guid' => '12345-123',
 694                          'resource_link_id' => '4b6fa'
 695                      ]
 696                  ],
 697                  'expected' => [
 698                      'PII' => self::PII_ALL,
 699                      'migrated' => true
 700                  ]
 701              ],
 702              'Existing (linked) platform learner including PII, no legacy user, no migration claim' => [
 703                  'legacy_data' => null,
 704                  'launch_data' => [
 705                      'has_authenticated_before' => true,
 706                      'user' => $this->get_mock_users_with_ids(
 707                          ['1'],
 708                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 709                      )[0],
 710                      'migration_claim' => null
 711                  ],
 712                  'expected' => [
 713                      'PII' => self::PII_ALL,
 714                  ]
 715              ],
 716              'Existing (linked) platform learner excluding PII, no legacy user, no migration claim' => [
 717                  'legacy_data' => null,
 718                  'launch_data' => [
 719                      'has_authenticated_before' => true,
 720                      'user' => $this->get_mock_users_with_ids(
 721                          ['1'],
 722                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
 723                          false,
 724                          false
 725                      )[0],
 726                      'migration_claim' => null
 727                  ],
 728                  'expected' => [
 729                      'PII' => self::PII_NONE,
 730                  ]
 731              ],
 732              'Existing (linked) platform instructor including PII, no legacy user, no migration claim' => [
 733                  'legacy_data' => null,
 734                  'launch_data' => [
 735                      'has_authenticated_before' => true,
 736                      'user' => $this->get_mock_users_with_ids(
 737                          ['1'],
 738                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'
 739                      )[0],
 740                      'migration_claim' => null
 741                  ],
 742                  'expected' => [
 743                      'PII' => self::PII_ALL,
 744                  ]
 745              ],
 746              'Existing (linked) platform instructor excluding PII, no legacy user, no migration claim' => [
 747                  'legacy_data' => null,
 748                  'launch_data' => [
 749                      'has_authenticated_before' => true,
 750                      'user' => $this->get_mock_users_with_ids(
 751                          ['1'],
 752                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
 753                          false,
 754                          false
 755                      )[0],
 756                      'migration_claim' => null
 757                  ],
 758                  'expected' => [
 759                      'PII' => self::PII_NONE,
 760                  ]
 761              ],
 762              'New (unlinked) platform instructor excluding PII, picture included' => [
 763                  'legacy_data' => null,
 764                  'launch_data' => [
 765                      'has_authenticated_before' => false,
 766                      'user' => $this->get_mock_users_with_ids(
 767                          ['1'],
 768                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
 769                          false,
 770                          false,
 771                          true
 772                      )[0],
 773                      'migration_claim' => null
 774                  ],
 775                  'expected' => [
 776                      'PII' => self::PII_NONE,
 777                      'syncpicture' => true
 778                  ]
 779              ]
 780          ];
 781      }
 782  
 783      /**
 784       * Test which verifies a user account can be created/found using the find_or_create_user_from_membership() method.
 785       *
 786       * @dataProvider membership_data_provider
 787       * @param array|null $legacydata legacy user and tool data, if testing migration cases.
 788       * @param array $memberdata data describing the membership data, including user data and legacy user id info.
 789       * @param string $iss the issuer URL string
 790       * @param string|null $legacyconsumerkey optional legacy consumer_key value for testing user migration
 791       * @param array $expected the test case expectations.
 792       * @covers ::find_or_create_user_from_membership
 793       */
 794      public function test_find_or_create_user_from_membership(?array $legacydata, array $memberdata, string $iss,
 795              ?string $legacyconsumerkey, array $expected) {
 796  
 797          $this->resetAfterTest();
 798          global $DB;
 799          $auth = get_auth_plugin('lti');
 800  
 801          // When testing platform users who have authenticated before, make that first auth call.
 802          if (!empty($memberdata['has_authenticated_before'])) {
 803              $mockmemberdata = $this->get_mock_member_data_for_user($memberdata['user'],
 804                  $memberdata['legacy_user_id'] ?? '');
 805              $firstauthuser = $auth->find_or_create_user_from_membership($mockmemberdata, $iss,
 806                  $legacyconsumerkey ?? '');
 807          }
 808  
 809          // Create legacy users and mocked tool secrets if desired.
 810          if ($legacydata) {
 811              $legacyusers = [];
 812              $generator = $this->getDataGenerator();
 813              foreach ($legacydata['users'] as $legacyuser) {
 814                  $username = 'enrol_lti' . sha1($legacydata['consumer_key'] . '::' . $legacydata['consumer_key'] .
 815                          ':' . $legacyuser['user_id']);
 816  
 817                  $legacyusers[] = $generator->create_user([
 818                      'username' => $username,
 819                      'auth' => 'lti'
 820                  ]);
 821              }
 822          }
 823  
 824          // Mock the membership data.
 825          $mockmemberdata = $this->get_mock_member_data_for_user($memberdata['user'], $memberdata['legacy_user_id'] ?? '');
 826  
 827          // Authenticate the platform user.
 828          $sink = $this->redirectEvents();
 829          $countusersbefore = $DB->count_records('user');
 830          $user = $auth->find_or_create_user_from_membership($mockmemberdata, $iss, $legacyconsumerkey ?? '');
 831          $countusersafter = $DB->count_records('user');
 832          $events = $sink->get_events();
 833          $sink->close();
 834  
 835          // Verify user count is correct. i.e. no user is created when migration claim is correctly processed or when
 836          // the user has authenticated with the tool before.
 837          $numnewusers = (!empty($expected['migrated'])) ? 0 : 1;
 838          $numnewusers = (!empty($memberdata['has_authenticated_before'])) ?
 839              0 : $numnewusers;
 840          $this->assertEquals($numnewusers, $countusersafter - $countusersbefore);
 841  
 842          // Verify PII is updated appropriately.
 843          switch ($expected['PII']) {
 844              case self::PII_ALL:
 845                  $this->assertEquals($memberdata['user']['given_name'], $user->firstname);
 846                  $this->assertEquals($memberdata['user']['family_name'], $user->lastname);
 847                  $this->assertEquals($memberdata['user']['email'], $user->email);
 848                  break;
 849              case self::PII_NAMES_ONLY:
 850                  $this->assertEquals($memberdata['user']['given_name'], $user->firstname);
 851                  $this->assertEquals($memberdata['user']['family_name'], $user->lastname);
 852                  $email = 'enrol_lti_13_' . sha1($iss . '_' . $mockmemberdata['user_id']) . "@example.com";
 853                  $this->assertEquals($email, $user->email);
 854                  break;
 855              case self::PII_EMAILS_ONLY:
 856                  $this->assertEquals($iss, $user->lastname);
 857                  $this->assertEquals($mockmemberdata['user_id'], $user->firstname);
 858                  $this->assertEquals($memberdata['user']['email'], $user->email);
 859                  break;
 860              default:
 861              case self::PII_NONE:
 862                  $this->assertEquals($iss, $user->lastname);
 863                  $this->assertEquals($mockmemberdata['user_id'], $user->firstname);
 864                  $email = 'enrol_lti_13_' . sha1($iss . '_' . $mockmemberdata['user_id']) . "@example.com";
 865                  $this->assertEquals($email, $user->email);
 866                  break;
 867          }
 868  
 869          if (!empty($expected['migrated'])) {
 870              // If migrated, verify the user account is reusing the legacy user account.
 871              $legacyuserids = array_column($legacyusers, 'id');
 872              $this->assertContains($user->id, $legacyuserids);
 873              $this->assertInstanceOf(\core\event\user_updated::class, $events[0]);
 874          } else if (isset($firstauthuser)) {
 875              // If the user is authenticating a second time, confirm the same account is being returned.
 876              $this->assertEquals($firstauthuser->id, $user->id);
 877              $this->assertEmpty($events); // The user authenticated with the same data once before, so we don't expect an update.
 878          } else {
 879              // The user wasn't migrated and hasn't launched before, so we expect a user_created event.
 880              $this->assertInstanceOf(\core\event\user_created::class, $events[0]);
 881          }
 882      }
 883  
 884      /**
 885       * Data provider for testing membership-service-based authentication.
 886       *
 887       * @return array the test case data.
 888       */
 889      public function membership_data_provider(): array {
 890          return [
 891              'New (unlinked) platform learner including PII, no legacy data, no consumer key bound, no legacy id' => [
 892                  'legacy_data' => null,
 893                  'membership_data' => [
 894                      'user' => $this->get_mock_users_with_ids(
 895                          ['1'],
 896                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 897                      )[0],
 898                  ],
 899                  'iss' => $this->issuer,
 900                  'legacy_consumer_key' => null,
 901                  'expected' => [
 902                      'PII' => self::PII_ALL,
 903                      'migrated' => false
 904                  ]
 905              ],
 906              'New (unlinked) platform learner excluding PII, no legacy data, no consumer key bound, no legacy id' => [
 907                  'legacy_data' => null,
 908                  'membership_data' => [
 909                      'user' => $this->get_mock_users_with_ids(
 910                          ['1'],
 911                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
 912                          false,
 913                          false
 914                      )[0],
 915                  ],
 916                  'iss' => $this->issuer,
 917                  'legacy_consumer_key' => null,
 918                  'expected' => [
 919                      'PII' => self::PII_NONE,
 920                      'migrated' => false
 921                  ]
 922              ],
 923              'New (unlinked) platform learner excluding names, no legacy data, no consumer key bound, no legacy id' => [
 924                  'legacy_data' => null,
 925                  'membership_data' => [
 926                      'user' => $this->get_mock_users_with_ids(
 927                          ['1'],
 928                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
 929                          false,
 930                      )[0],
 931                  ],
 932                  'iss' => $this->issuer,
 933                  'legacy_consumer_key' => null,
 934                  'expected' => [
 935                      'PII' => self::PII_EMAILS_ONLY,
 936                      'migrated' => false
 937                  ]
 938              ],
 939              'New (unlinked) platform learner excluding email, no legacy data, no consumer key bound, no legacy id' => [
 940                  'legacy_data' => null,
 941                  'membership_data' => [
 942                      'user' => $this->get_mock_users_with_ids(
 943                          ['1'],
 944                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner',
 945                          true,
 946                          false
 947                      )[0],
 948                  ],
 949                  'iss' => $this->issuer,
 950                  'legacy_consumer_key' => null,
 951                  'expected' => [
 952                      'PII' => self::PII_NAMES_ONLY,
 953                      'migrated' => false
 954                  ]
 955              ],
 956              'New (unlinked) platform learner including PII, legacy user, consumer key bound, legacy user id sent' => [
 957                  'legacy_data' => [
 958                      'users' => [
 959                          ['user_id' => '123-abc'],
 960                      ],
 961                      'consumer_key' => 'CONSUMER_1',
 962                  ],
 963                  'membership_data' => [
 964                      'user' => $this->get_mock_users_with_ids(
 965                          ['1'],
 966                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 967                      )[0],
 968                      'legacy_user_id' => '123-abc'
 969                  ],
 970                  'iss' => $this->issuer,
 971                  'legacy_consumer_key' => 'CONSUMER_1',
 972                  'expected' => [
 973                      'PII' => self::PII_ALL,
 974                      'migrated' => true
 975                  ]
 976              ],
 977              'New (unlinked) platform learner including PII, legacy user, consumer key bound, legacy user id omitted' => [
 978                  'legacy_data' => [
 979                      'users' => [
 980                          ['user_id' => '123-abc'],
 981                      ],
 982                      'consumer_key' => 'CONSUMER_1',
 983                  ],
 984                  'membership_data' => [
 985                      'user' => $this->get_mock_users_with_ids(
 986                          ['1'],
 987                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
 988                      )[0],
 989                  ],
 990                  'iss' => $this->issuer,
 991                  'legacy_consumer_key' => 'CONSUMER_1',
 992                  'expected' => [
 993                      'PII' => self::PII_ALL,
 994                      'migrated' => false,
 995                  ]
 996              ],
 997              'New (unlinked) platform learner including PII, legacy user, consumer key bound, no change in user id' => [
 998                  'legacy_data' => [
 999                      'users' => [
1000                          ['user_id' => '123-abc'],
1001                      ],
1002                      'consumer_key' => 'CONSUMER_1',
1003                  ],
1004                  'membership_data' => [
1005                      'user' => $this->get_mock_users_with_ids(
1006                          ['123-abc'],
1007                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
1008                      )[0],
1009                  ],
1010                  'iss' => $this->issuer,
1011                  'legacy_consumer_key' => 'CONSUMER_1',
1012                  'expected' => [
1013                      'PII' => self::PII_ALL,
1014                      'migrated' => true
1015                  ]
1016              ],
1017              'New (unlinked) platform learner including PII, legacy user, unexpected consumer key bound, no change in user id' => [
1018                  'legacy_data' => [
1019                      'users' => [
1020                          ['user_id' => '123-abc'],
1021                      ],
1022                      'consumer_key' => 'CONSUMER_1',
1023                  ],
1024                  'membership_data' => [
1025                      'user' => $this->get_mock_users_with_ids(
1026                          ['123-abc'],
1027                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
1028                      )[0],
1029                  ],
1030                  'iss' => $this->issuer,
1031                  'legacy_consumer_key' => 'CONSUMER_ABCDEF',
1032                  'expected' => [
1033                      'PII' => self::PII_ALL,
1034                      'migrated' => false,
1035                  ]
1036              ],
1037              'New (unlinked) platform learner including PII, legacy user, consumer key not bound, legacy user id sent' => [
1038                  'legacy_data' => [
1039                      'users' => [
1040                          ['user_id' => '123-abc'],
1041                      ],
1042                      'consumer_key' => 'CONSUMER_1',
1043                  ],
1044                  'membership_data' => [
1045                      'user' => $this->get_mock_users_with_ids(
1046                          ['1'],
1047                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
1048                      )[0],
1049                      'legacy_user_id' => '123-abc'
1050                  ],
1051                  'iss' => $this->issuer,
1052                  'legacy_consumer_key' => null,
1053                  'expected' => [
1054                      'PII' => self::PII_ALL,
1055                      'migrated' => false
1056                  ]
1057              ],
1058              'New (unlinked) platform learner including PII, no legacy data, consumer key bound, legacy user id sent' => [
1059                  'legacy_data' => null,
1060                  'membership_data' => [
1061                      'user' => $this->get_mock_users_with_ids(
1062                          ['1'],
1063                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
1064                      )[0],
1065                      'legacy_user_id' => '123-abc'
1066                  ],
1067                  'iss' => $this->issuer,
1068                  'legacy_consumer_key' => 'CONSUMER_1',
1069                  'expected' => [
1070                      'PII' => self::PII_ALL,
1071                      'migrated' => false
1072                  ]
1073              ],
1074              'New (unlinked) platform instructor including PII, no legacy data, no consumer key bound, no legacy id' => [
1075                  'legacy_data' => null,
1076                  'membership_data' => [
1077                      'user' => $this->get_mock_users_with_ids(
1078                          ['1'],
1079                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor'
1080                      )[0],
1081                  ],
1082                  'iss' => $this->issuer,
1083                  'legacy_consumer_key' => null,
1084                  'expected' => [
1085                      'PII' => self::PII_ALL,
1086                      'migrated' => false
1087                  ]
1088              ],
1089              'New (unlinked) platform instructor excluding PII, no legacy data, no consumer key bound, no legacy id' => [
1090                  'legacy_data' => null,
1091                  'membership_data' => [
1092                      'user' => $this->get_mock_users_with_ids(
1093                          ['1'],
1094                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
1095                          false,
1096                          false
1097                      )[0],
1098                  ],
1099                  'iss' => $this->issuer,
1100                  'legacy_consumer_key' => null,
1101                  'expected' => [
1102                      'PII' => self::PII_NONE,
1103                      'migrated' => false
1104                  ]
1105              ],
1106              'Existing (linked) platform learner including PII, no legacy data, no consumer key bound, no legacy id' => [
1107                  'legacy_data' => null,
1108                  'launch_data' => [
1109                      'has_authenticated_before' => true,
1110                      'user' => $this->get_mock_users_with_ids(
1111                          ['1'],
1112                          'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
1113                      )[0],
1114                  ],
1115                  'iss' => $this->issuer,
1116                  'legacy_consumer_key' => null,
1117                  'expected' => [
1118                      'PII' => self::PII_ALL,
1119                      'migrated' => false
1120                  ]
1121              ],
1122          ];
1123      }
1124  
1125      /**
1126       * Test the behaviour of create_user_binding().
1127       *
1128       * @covers ::create_user_binding
1129       */
1130      public function test_create_user_binding() {
1131          $this->resetAfterTest();
1132          global $DB;
1133          $auth = get_auth_plugin('lti');
1134          $user = $this->getDataGenerator()->create_user();
1135          $mockiss = $this->issuer;
1136          $mocksub = '1';
1137  
1138          // Create a binding and verify it exists.
1139          $this->assertFalse($DB->record_exists('auth_lti_linked_login', ['userid' => $user->id]));
1140          $auth->create_user_binding($mockiss, $mocksub, $user->id);
1141          $this->assertTrue($DB->record_exists('auth_lti_linked_login', ['userid' => $user->id]));
1142  
1143          // Now, try to get an authenticated user USING that binding. Verify the bound user is returned.
1144          $numusersbefore = $DB->count_records('user');
1145          $matcheduser = $auth->find_or_create_user_from_launch(
1146              $this->get_mock_launchdata_for_user(
1147                  $this->get_mock_users_with_ids([$mocksub])[0]
1148              )
1149          );
1150          $numusersafter = $DB->count_records('user');
1151          $this->assertEquals($numusersafter, $numusersbefore);
1152          $this->assertEquals($user->id, $matcheduser->id);
1153  
1154          // Assert idempotency of the bind call.
1155          $this->assertNull($auth->create_user_binding($mockiss, $mocksub, $user->id));
1156      }
1157  }