See Release Notes
Long Term Support Release
Differences Between: [Versions 401 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']) && $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']) && $expected['migrated']) ? 0 : 1; 265 $numnewusers = (!empty($launchdata['has_authenticated_before']) && $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']) && $expected['syncpicture']) { 298 $this->verify_user_profile_image_updated($user->id); 299 } 300 301 if (!empty($expected['migrated']) && $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']) && $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']) && $expected['migrated']) ? 0 : 1; 838 $numnewusers = (!empty($memberdata['has_authenticated_before']) && $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']) && $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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body