From 1f27bad809eac7786f3f73362378407943fca14b Mon Sep 17 00:00:00 2001 From: Jake Dallimore Date: Mon, 24 Jan 2022 17:20:19 +0800 Subject: [PATCH] MDL-69542 enrol_lti: add LTI Advantage member sync task This change adds a new member sync task for LTI Advantage and updates the legacy task such that it only operates on legacy tools. This uses the names and roles provisioning service 2.0. --- .../local/ltiadvantage/task/sync_members.php | 454 ++++++++ enrol/lti/classes/task/sync_members.php | 3 +- enrol/lti/db/tasks.php | 18 + .../local/ltiadvantage/task/sync_members_test.php | 1224 ++++++++++++++++++++ enrol/lti/version.php | 2 +- 5 files changed, 1699 insertions(+), 2 deletions(-) create mode 100644 enrol/lti/classes/local/ltiadvantage/task/sync_members.php create mode 100644 enrol/lti/tests/local/ltiadvantage/task/sync_members_test.php diff --git a/enrol/lti/classes/local/ltiadvantage/task/sync_members.php b/enrol/lti/classes/local/ltiadvantage/task/sync_members.php new file mode 100644 index 00000000000..2f59664c995 --- /dev/null +++ b/enrol/lti/classes/local/ltiadvantage/task/sync_members.php @@ -0,0 +1,454 @@ +. + +namespace enrol_lti\local\ltiadvantage\task; + +use core\task\scheduled_task; +use enrol_lti\helper; +use enrol_lti\local\ltiadvantage\entity\application_registration; +use enrol_lti\local\ltiadvantage\entity\nrps_info; +use enrol_lti\local\ltiadvantage\entity\resource_link; +use enrol_lti\local\ltiadvantage\entity\user; +use enrol_lti\local\ltiadvantage\lib\http_client; +use enrol_lti\local\ltiadvantage\lib\issuer_database; +use enrol_lti\local\ltiadvantage\lib\launch_cache_session; +use enrol_lti\local\ltiadvantage\repository\application_registration_repository; +use enrol_lti\local\ltiadvantage\repository\deployment_repository; +use enrol_lti\local\ltiadvantage\repository\resource_link_repository; +use enrol_lti\local\ltiadvantage\repository\user_repository; +use Packback\Lti1p3\LtiNamesRolesProvisioningService; +use Packback\Lti1p3\LtiRegistration; +use Packback\Lti1p3\LtiServiceConnector; +use stdClass; + +/** + * LTI Advantage-specific task responsible for syncing memberships from tool platforms with the tool. + * + * This task may gather members from a context-level service call, depending on whether a resource-level service call + * (which is made first) was successful. Because of the context-wide memberships, and because each published resource + * has per-resource access control (role assignments), this task only enrols user into the course, and does not assign + * roles to resource/course contexts. Role assignment only takes place during a launch, via the tool_launch_service. + * + * @package enrol_lti + * @copyright 2021 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sync_members extends scheduled_task { + + /** @var array Array of user photos. */ + protected $userphotos = []; + + /** @var resource_link_repository $resourcelinkrepo for fetching resource_link instances.*/ + protected $resourcelinkrepo; + + /** @var application_registration_repository $appregistrationrepo for fetching application_registration instances.*/ + protected $appregistrationrepo; + + /** @var deployment_repository $deploymentrepo for fetching deployment instances. */ + protected $deploymentrepo; + + /** @var user_repository $userrepo for fetching and saving lti user information.*/ + protected $userrepo; + + /** @var issuer_database $issuerdb library specific registration DB required to create service connectors.*/ + protected $issuerdb; + + /** + * Get the name for this task. + * + * @return string the name of the task. + */ + public function get_name(): string { + return get_string('tasksyncmembers', 'enrol_lti'); + } + + /** + * Make a resource-link-level memberships call. + * + * @param nrps_info $nrps information about names and roles service endpoints and scopes. + * @param LtiServiceConnector $sc a service connector object. + * @param LtiRegistration $registration the registration + * @param resource_link $resourcelink the resource link + * @return array an array of members if found. + */ + protected function get_resource_link_level_members(nrps_info $nrps, LtiServiceConnector $sc, LtiRegistration $registration, + resource_link $resourcelink) { + + // Try a resource-link-level memberships call first, falling back to context-level if no members are found. + $reslinkmembershipsurl = $nrps->get_context_memberships_url(); + $reslinkmembershipsurl->param('rlid', $resourcelink->get_resourcelinkid()); + $servicedata = [ + 'context_memberships_url' => $reslinkmembershipsurl->out(false) + ]; + $reslinklevelnrps = new LtiNamesRolesProvisioningService($sc, $registration, $servicedata); + + mtrace('Making resource-link-level memberships request'); + return $reslinklevelnrps->getMembers(); + } + + /** + * Make a context-level memberships call. + * + * @param nrps_info $nrps information about names and roles service endpoints and scopes. + * @param LtiServiceConnector $sc a service connector object. + * @param LtiRegistration $registration the registration + * @return array an array of members. + */ + protected function get_context_level_members(nrps_info $nrps, LtiServiceConnector $sc, LtiRegistration $registration) { + $clservicedata = [ + 'context_memberships_url' => $nrps->get_context_memberships_url()->out(false) + ]; + $contextlevelnrps = new LtiNamesRolesProvisioningService($sc, $registration, $clservicedata); + + return $contextlevelnrps->getMembers(); + } + + /** + * Make the NRPS service call and fetch members based on the given resource link. + * + * Memberships will be retrieved by first trying the link-level memberships service first, falling back to calling + * the context-level memberships service only if the link-level call fails. + * + * @param application_registration $appregistration an application registration instance. + * @param resource_link $resourcelink a resourcelink instance. + * @return array an array of members. + */ + protected function get_members_from_resource_link(application_registration $appregistration, + resource_link $resourcelink) { + + // Get a service worker for the corresponding application registration. + $registration = $this->issuerdb->findRegistrationByIssuer( + $appregistration->get_platformid()->out(false), + $appregistration->get_clientid() + ); + global $CFG; + require_once($CFG->libdir . '/filelib.php'); + $sc = new LtiServiceConnector(new launch_cache_session(), new http_client(new \curl())); + + $nrps = $resourcelink->get_names_and_roles_service(); + try { + $members = $this->get_resource_link_level_members($nrps, $sc, $registration, $resourcelink); + } catch (\Exception $e) { + mtrace('Link-level memberships request failed. Making context-level memberships request'); + $members = $this->get_context_level_members($nrps, $sc, $registration); + } + + return $members; + } + + /** + * Performs the synchronisation of members. + */ + public function execute() { + if (!is_enabled_auth('lti')) { + mtrace('Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti'))); + return; + } + if (!enrol_is_enabled('lti')) { + mtrace('Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti')); + return; + } + $this->resourcelinkrepo = new resource_link_repository(); + $this->appregistrationrepo = new application_registration_repository(); + $this->deploymentrepo = new deployment_repository(); + $this->userrepo = new user_repository(); + $this->issuerdb = new issuer_database($this->appregistrationrepo, $this->deploymentrepo); + + $resources = helper::get_lti_tools(['status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1, + 'ltiversion' => 'LTI-1p3']); + + foreach ($resources as $resource) { + mtrace("Starting - Member sync for published resource '$resource->id' for course '$resource->courseid'."); + $usercount = 0; + $enrolcount = 0; + $unenrolcount = 0; + $syncedusers = []; + + // Get all resource_links for this shared resource. + // This is how context/resource_link memberships calls will be made. + $resourcelinks = $this->resourcelinkrepo->find_by_resource((int)$resource->id); + foreach ($resourcelinks as $resourcelink) { + mtrace("Requesting names and roles for the resource link '{$resourcelink->get_id()}' for the resource" . + " '{$resource->id}'"); + + if (!$resourcelink->get_names_and_roles_service()) { + mtrace("Skipping - No names and roles service found."); + continue; + } + + $appregistration = $this->appregistrationrepo->find_by_deployment( + $resourcelink->get_deploymentid() + ); + if (!$appregistration) { + mtrace("Skipping - no corresponding application registration found."); + continue; + } + + try { + $members = $this->get_members_from_resource_link($appregistration, $resourcelink); + } catch (\Exception $e) { + mtrace("Skipping - Names and Roles service request failed: {$e->getMessage()}."); + continue; + } + + // Fetched members count. + $membercount = count($members); + $usercount += $membercount; + mtrace("$membercount members received."); + + // Process member information. + [$rlenrolcount, $userids] = $this->sync_member_information($appregistration, $resource, + $resourcelink, $members); + $enrolcount += $rlenrolcount; + + // Update the list of users synced for this shared resource or its context. + $syncedusers = array_unique(array_merge($syncedusers, $userids)); + + mtrace("Completed - Synced $membercount members for the resource link '{$resourcelink->get_id()}' ". + "for the resource '{$resource->id}'.\n"); + + // Sync unenrolments on a per-resource-link basis so we have fine grained control over unenrolments. + // If a resource link doesn't support NRPS, it will already have been skipped. + $unenrolcount += $this->sync_unenrol_resourcelink($resourcelink, $resource, $syncedusers); + } + + mtrace("Completed - Synced members for tool '$resource->id' in the course '$resource->courseid'. " . + "Processed $usercount users; enrolled $enrolcount members; unenrolled $unenrolcount members.\n"); + } + + if (!empty($resources) && !empty($this->userphotos)) { + // Sync the user profile photos. + mtrace("Started - Syncing user profile images."); + $countsyncedimages = $this->sync_profile_images(); + mtrace("Completed - Synced $countsyncedimages profile images."); + } + } + + /** + * Process unenrolment of users for a given resource link and based on the list of recently synced users. + * + * @param resource_link $resourcelink the resource_link instance to which the $synced users pertains + * @param stdClass $resource the resource object instance + * @param array $syncedusers the array of recently synced users, who are not to be unenrolled. + * @return int the number of unenrolled users. + */ + protected function sync_unenrol_resourcelink(resource_link $resourcelink, stdClass $resource, + array $syncedusers): int { + + if (!$this->should_sync_unenrol($resource->membersyncmode)) { + return 0; + } + $ltiplugin = enrol_get_plugin('lti'); + $unenrolcount = 0; + + // Get all users for the resource_link instance. + $linkusers = $this->userrepo->find_by_resource_link($resourcelink->get_id()); + + foreach ($linkusers as $ltiuser) { + if (!in_array($ltiuser->get_localid(), $syncedusers)) { + $instance = new stdClass(); + $instance->id = $resource->enrolid; + $instance->courseid = $resource->courseid; + $instance->enrol = 'lti'; + $ltiplugin->unenrol_user($instance, $ltiuser->get_localid()); + $unenrolcount++; + } + } + return $unenrolcount; + } + + /** + * Check whether the member has an instructor role or not. + * + * @param array $member + * @return bool + */ + protected function member_is_instructor(array $member): bool { + // See: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies. + $memberroles = $member['roles']; + if ($memberroles) { + $adminroles = [ + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator', + 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator' + ]; + $staffroles = [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper', + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant', + 'ContentDeveloper', + 'Instructor', + 'Instructor#TeachingAssistant' + ]; + $instructorroles = array_merge($adminroles, $staffroles); + + foreach ($instructorroles as $validrole) { + if (in_array($validrole, $memberroles)) { + return true; + } + } + } + return false; + } + + /** + * Method to determine whether to sync unenrolments or not. + * + * @param int $syncmode The shared resource's membersyncmode. + * @return bool true if unenrolment should be synced, false if not. + */ + protected function should_sync_unenrol($syncmode): bool { + return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_UNENROL_MISSING; + } + + /** + * Method to determine whether to sync enrolments or not. + * + * @param int $syncmode The shared resource's membersyncmode. + * @return bool true if enrolment should be synced, false if not. + */ + protected function should_sync_enrol($syncmode): bool { + return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_ENROL_NEW; + } + + /** + * Creates an lti user object from a member entry. + * + * @param stdClass $user the Moodle user record representing this member. + * @param stdClass $resource the locally published resource record, used for setting user defaults. + * @param resource_link $resourcelink the resource_link instance. + * @param array $member the member information from the NRPS service call. + * @return user the lti user instance. + */ + protected function ltiuser_from_member(stdClass $user, stdClass $resource, + resource_link $resourcelink, array $member): user { + + if (!$ltiuser = $this->userrepo->find_single_user_by_resource($user->id, $resource->id)) { + // New user, so create them. + $ltiuser = user::create( + $resourcelink->get_resourceid(), + $user->id, + $resourcelink->get_deploymentid(), + $member['user_id'], + $resource->lang, + $resource->timezone, + $resource->city ?? '', + $resource->country ?? '', + $resource->institution ?? '', + $resource->maildisplay + ); + } + $ltiuser->set_lastaccess(time()); + return $ltiuser; + } + + /** + * Performs synchronisation of member information and enrolments. + * + * @param application_registration $appregistration the application_registration instance. + * @param stdClass $resource the enrol_lti_tools resource information. + * @param resource_link $resourcelink the resource_link instance. + * @param user[] $members an array of members to sync. + * @return array An array containing the counts of enrolled users and a list of userids. + */ + protected function sync_member_information(application_registration $appregistration, stdClass $resource, + resource_link $resourcelink, array $members): array { + + $enrolcount = 0; + $userids = []; + + // Get the verified legacy consumer key, if mapped, from the resource link's tool deployment. + // This will be used to locate legacy user accounts and link them to LTI 1.3 users. + // A launch must have been made in order to get the legacy consumer key from the lti1p1 migration claim. + $deployment = $this->deploymentrepo->find($resourcelink->get_deploymentid()); + $legacyconsumerkey = $deployment->get_legacy_consumer_key() ?? ''; + + foreach ($members as $member) { + $auth = get_auth_plugin('lti'); + if ($auth->get_user_binding($appregistration->get_platformid()->out(false), $member['user_id'])) { + // Use is bound already, so we can update them. + $user = $auth->find_or_create_user_from_membership($member, $appregistration->get_platformid()->out(false)); + if ($user->auth != 'lti') { + mtrace("Skipped profile sync for user '$user->id'. The user does not belong to the LTI auth method."); + } + } else { + // Not bound, so defer to the role-based provisioning mode for the resource. + $provisioningmode = $this->member_is_instructor($member) ? $resource->provisioningmodeinstructor : + $resource->provisioningmodelearner; + switch ($provisioningmode) { + case \auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY: + // Automatic provisioning - this will create a user account and log the user in. + $user = $auth->find_or_create_user_from_membership($member, $appregistration->get_platformid()->out(false), + $legacyconsumerkey); + break; + case \auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING: + case \auth_plugin_lti::PROVISIONING_MODE_PROMPT_EXISTING_ONLY: + default: + mtrace("Skipping account creation for member '{$member['user_id']}'. This member is not eligible for ". + "automatic creation due to the current account provisioning mode."); + continue 2; + } + } + + $ltiuser = $this->ltiuser_from_member($user, $resource, $resourcelink, $member); + + if ($this->should_sync_enrol($resource->membersyncmode)) { + + $ltiuser->set_resourcelinkid($resourcelink->get_id()); + $ltiuser = $this->userrepo->save($ltiuser); + if ($user->auth != 'lti') { + mtrace("Skipped picture sync for user '$user->id'. The user does not belong to the LTI auth method."); + } else { + if (isset($member['picture'])) { + $this->userphotos[$ltiuser->get_localid()] = $member['picture']; + } + } + + // Enrol the user in the course. + if (helper::enrol_user($resource, $ltiuser->get_localid()) === helper::ENROLMENT_SUCCESSFUL) { + $enrolcount++; + } + } + + // If the member has been created, or exists locally already, mark them as valid so as to not unenrol them + // when syncing memberships for shared resources configured as either MEMBER_SYNC_ENROL_AND_UNENROL or + // MEMBER_SYNC_UNENROL_MISSING. + $userids[] = $user->id; + } + + return [$enrolcount, $userids]; + } + + /** + * Performs synchronisation of user profile images. + * + * @return int the count of synced photos. + */ + protected function sync_profile_images(): int { + $counter = 0; + foreach ($this->userphotos as $userid => $url) { + if ($url) { + $result = helper::update_user_profile_image($userid, $url); + if ($result === helper::PROFILE_IMAGE_UPDATE_SUCCESSFUL) { + $counter++; + mtrace("Profile image successfully downloaded and created for user '$userid' from $url."); + } else { + mtrace($result); + } + } + } + return $counter; + } +} diff --git a/enrol/lti/classes/task/sync_members.php b/enrol/lti/classes/task/sync_members.php index ae71570a1f8..dca4becd58c 100644 --- a/enrol/lti/classes/task/sync_members.php +++ b/enrol/lti/classes/task/sync_members.php @@ -84,7 +84,8 @@ class sync_members extends scheduled_task { $this->dataconnector = new data_connector(); // Get all the enabled tools. - $tools = helper::get_lti_tools(array('status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1)); + $tools = helper::get_lti_tools(array('status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1, + 'ltiversion' => 'LTI-1p0/LTI-2p0')); foreach ($tools as $tool) { mtrace("Starting - Member sync for published tool '$tool->id' for course '$tool->courseid'."); diff --git a/enrol/lti/db/tasks.php b/enrol/lti/db/tasks.php index 9c8b9f2ec19..34d26022f8b 100644 --- a/enrol/lti/db/tasks.php +++ b/enrol/lti/db/tasks.php @@ -41,4 +41,22 @@ $tasks = array( 'dayofweek' => '*', 'month' => '*' ), + array( + 'classname' => 'enrol_lti\local\ltiadvantage\task\sync_members', + 'blocking' => 0, + 'minute' => '*/30', + 'hour' => '*', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*' + ), + array( + 'classname' => 'enrol_lti\local\ltiadvantage\task\sync_grades', + 'blocking' => 0, + 'minute' => '*/30', + 'hour' => '*', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*' + ), ); diff --git a/enrol/lti/tests/local/ltiadvantage/task/sync_members_test.php b/enrol/lti/tests/local/ltiadvantage/task/sync_members_test.php new file mode 100644 index 00000000000..06cf3483efc --- /dev/null +++ b/enrol/lti/tests/local/ltiadvantage/task/sync_members_test.php @@ -0,0 +1,1224 @@ +. + +namespace enrol_lti\local\ltiadvantage\task; + +use enrol_lti\helper; +use enrol_lti\local\ltiadvantage\entity\user; +use enrol_lti\local\ltiadvantage\repository\resource_link_repository; +use enrol_lti\local\ltiadvantage\repository\user_repository; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../lti_advantage_testcase.php'); + +/** + * Tests for the enrol_lti\local\ltiadvantage\task\sync_members scheduled task. + * + * @package enrol_lti + * @copyright 2021 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \enrol_lti\local\ltiadvantage\task\sync_members + */ +class sync_members_test extends \lti_advantage_testcase { + + /** + * Verify the user's profile picture has been set, which is useful to verify picture syncs. + * + * @param int $userid the id of the Moodle user. + * @param bool $match true to verify a match, false to verify a non-match. + */ + protected function verify_user_profile_image(int $userid, bool $match = true): void { + global $CFG; + $user = \core_user::get_user($userid); + $usercontext = \context_user::instance($user->id); + $expected = $CFG->wwwroot . '/pluginfile.php/' . $usercontext->id . '/user/icon/boost/f2?rev='. $user->picture; + + $page = new \moodle_page(); + $page->set_url('/user/profile.php'); + $page->set_context(\context_system::instance()); + $renderer = $page->get_renderer('core'); + $userpicture = new \user_picture($user); + if ($match) { + $this->assertEquals($expected, $userpicture->get_url($page, $renderer)->out(false)); + } else { + $this->assertNotEquals($expected, $userpicture->get_url($page, $renderer)->out(false)); + } + + } + + /** + * Helper to get a list of mocked member entries for use in the mocked sync task. + * + * @param array $userids the array of lti user ids to use. + * @param array|null $legacyuserids legacy user ids for the lti11_legacy_user_id property, null if not desired. + * @param bool $names whether to include names in the user data or not. + * @param bool $emails whether to include email in the user data or not. + * @param bool $linklevel whether to mock the user return data at link-level (true) or context-level (false). + * @param bool $picture whether to mock a user's picture field in the return data. + * @param array $roles an array of IMS roles to include with each member which, if empty, defaults to just the learner role. + * @return array the array of users. + * @throws \Exception if the legacyuserids array doesn't contain the correct number of ids. + */ + protected function get_mock_members_with_ids(array $userids, ?array $legacyuserids = null, $names = true, + $emails = true, bool $linklevel = true, bool $picture = false, array $roles = []): array { + + if (!is_null($legacyuserids) && count($legacyuserids) != count($userids)) { + throw new \Exception('legacyuserids must contain the same number of ids as $userids.'); + } + + if (empty($roles)) { + $roles = ['http://purl.imsglobal.org/vocab/lis/v2/membership#Learner']; + } + + $users = []; + foreach ($userids as $userid) { + $user = ['user_id' => (string) $userid, 'roles' => $roles]; + if ($picture) { + $user['picture'] = $this->getExternalTestFileUrl('/test.jpg', false); + } + if ($names) { + $user['given_name'] = 'Firstname' . $userid; + $user['family_name'] = 'Surname' . $userid; + } + if ($emails) { + $user['email'] = "firstname.surname{$userid}@lms.example.org"; + } + if ($legacyuserids) { + $user['lti11_legacy_user_id'] = array_shift($legacyuserids); + } + if ($linklevel) { + // Link-level memberships also include a message property. + $user['message'] = [ + 'https://purl.imsglobal.org/spec/lti/claim/message_type' => 'LtiResourceLinkRequest' + ]; + } + $users[] = $user; + } + return $users; + } + + /** + * Gets a task mocked to only support resource-link-level memberships request. + * + * @param array $resourcelinks array for stipulating per link users, containing list of [resourcelink, members]. + * @return sync_members|\PHPUnit\Framework\MockObject\MockObject + */ + protected function get_mock_task_resource_link_level(array $resourcelinks = []) { + $mocktask = $this->getMockBuilder(sync_members::class) + ->onlyMethods(['get_resource_link_level_members', 'get_context_level_members']) + ->getMock(); + $mocktask->expects($this->any()) + ->method('get_context_level_members') + ->will($this->returnCallback(function() { + return false; + })); + $expectedcount = !empty($resourcelinks) ? count($resourcelinks) : 1; + $mocktask->expects($this->exactly($expectedcount)) + ->method('get_resource_link_level_members') + ->will($this->returnCallback(function ($nrpsinfo, $serviceconnector, $registration, $reslink) use ($resourcelinks) { + if ($resourcelinks) { + foreach ($resourcelinks as $rl) { + if ($reslink->get_resourcelinkid() === $rl[0]->get_resourcelinkid()) { + return $rl[1]; + } + } + } else { + return $this->get_mock_members_with_ids(range(1, 2)); + } + })); + return $mocktask; + } + + /** + * Gets a task mocked to only support context-level memberships request. + * + * @return sync_members|\PHPUnit\Framework\MockObject\MockObject + */ + protected function get_mock_task_context_level() { + $mocktask = $this->getMockBuilder(sync_members::class) + ->onlyMethods(['get_resource_link_level_members', 'get_context_level_members']) + ->getMock(); + $mocktask->expects($this->any()) + ->method('get_resource_link_level_members') + ->will($this->returnCallback(function() { + // An exception is what the service code will throw if the resource link level service isn't available. + throw new \Exception(); + })); + $mocktask->expects($this->any()) + ->method('get_context_level_members') + ->will($this->returnCallback(function() { + return $this->get_mock_members_with_ids(range(1, 3), null, true, true, false); + }));; + return $mocktask; + } + + /** + * Gets a sync task, with the remote calls mocked to return the supplied users. + * + * See get_mock_members_with_ids() for generating the users for input. + * + * @param array $users a list of users, the result of a call to get_mock_members_with_ids(). + * @return \PHPUnit\Framework\MockObject\MockObject the mock task. + */ + protected function get_mock_task_with_users(array $users) { + $mocktask = $this->getMockBuilder(sync_members::class) + ->onlyMethods(['get_resource_link_level_members', 'get_context_level_members']) + ->getMock(); + $mocktask->expects($this->any()) + ->method('get_context_level_members') + ->will($this->returnCallback(function() { + return false; + })); + $mocktask->expects($this->any()) + ->method('get_resource_link_level_members') + ->will($this->returnCallback(function () use ($users) { + return $users; + })); + return $mocktask; + } + + /** + * Check that all the given ltiusers are enrolled in the course. + * + * @param \stdClass $course the course instance. + * @param user[] $ltiusers array of lti user instances. + */ + protected function verify_course_enrolments(\stdClass $course, array $ltiusers) { + global $CFG; + require_once($CFG->libdir . '/enrollib.php'); + $enrolledusers = get_enrolled_users(\context_course::instance($course->id)); + $this->assertCount(count($ltiusers), $enrolledusers); + $enrolleduserids = array_map(function($stringid) { + return (int) $stringid; + }, array_column($enrolledusers, 'id')); + foreach ($ltiusers as $ltiuser) { + $this->assertContains($ltiuser->get_localid(), $enrolleduserids); + } + } + + /** + * Test confirming task name. + * + * @covers ::get_name + */ + public function test_get_name() { + $this->assertEquals(get_string('tasksyncmembers', 'enrol_lti'), (new sync_members())->get_name()); + } + + /** + * Test a resource-link-level membership sync, confirming that all relevant domain objects are updated properly. + * + * @covers ::execute + */ + public function test_resource_link_level_sync() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + + // Launch the tool for a user. + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'])[0]); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice = $this->get_tool_launch_service(); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + + // Sync members. + $task = $this->get_mock_task_resource_link_level(); + $task->execute(); + + // Verify 2 users and their corresponding course enrolments exist. + $this->expectOutputRegex( + "/Completed - Synced members for tool '$resource->id' in the course '$course->id'. ". + "Processed 2 users; enrolled 2 members; unenrolled 0 members./" + ); + $userrepo = new user_repository(); + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(2, $ltiusers); + $this->verify_course_enrolments($course, $ltiusers); + } + + /** + * Test a resource-link-level membership sync when there are more than one resource links for the resource. + * + * @covers ::execute + */ + public function test_resource_link_level_sync_multiple_resource_links() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + + // Launch twice - once from each resource link in the platform. + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'])[0], '123'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'])[0], '456'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + + // Now, grab the resource links. + $rlrepo = new resource_link_repository(); + $reslinks = $rlrepo->find_by_resource($resource->id); + $mockmembers = $this->get_mock_members_with_ids(range(1, 10)); + $mockusers1 = array_slice($mockmembers, 0, 6); + $mockusers2 = array_slice($mockmembers, 6); + $resourcelinks = [ + [$reslinks[0], $mockusers1], + [$reslinks[1], $mockusers2] + ]; + + // Sync the members, using the mock task set up to sync different sets of users for each resource link. + $task = $this->get_mock_task_resource_link_level($resourcelinks); + ob_start(); + $task->execute(); + $output = ob_get_contents(); + ob_end_clean(); + + // Verify 10 users and their corresponding course enrolments exist. + $userrepo = new user_repository(); + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(10, $ltiusers); + $this->assertStringContainsString("Completed - Synced 6 members for the resource link", $output); + $this->assertStringContainsString("Completed - Synced 4 members for the resource link", $output); + $this->assertStringContainsString("Completed - Synced members for tool '$resource->id' in the course '". + "$resource->courseid'. Processed 10 users; enrolled 10 members; unenrolled 0 members.\n", $output); + $this->verify_course_enrolments($course, $ltiusers); + } + + /** + * Verify the task will update users' profile pictures if the 'picture' member field is provided. + * + * @covers ::execute + */ + public function test_user_profile_image_sync() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + + // Launch the tool for a user. + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'])[0]); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + + // Sync members. + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(['1'], null, true, true, true, true)); + ob_start(); + $task->execute(); + ob_end_clean(); + + // Verify 1 users and their corresponding course enrolments exist. + $userrepo = new user_repository(); + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(1, $ltiusers); + $this->verify_course_enrolments($course, $ltiusers); + + // Verify user profile image has been updated. + $this->verify_user_profile_image($ltiusers[0]->get_localid()); + } + + /** + * Test a context-level membership sync, confirming that all relevant domain objects are updated properly. + * + * @covers ::execute + */ + public function test_context_level_sync() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + + // Launch the tool for a user. + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'])[0]); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + + // Sync members. + $task = $this->get_mock_task_context_level(); + ob_start(); + $task->execute(); + ob_end_clean(); + + // Verify 3 users and their corresponding course enrolments exist. + $userrepo = new user_repository(); + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(3, $ltiusers); + $this->verify_course_enrolments($course, $ltiusers); + } + + /** + * Test verifying the sync task handles the omission/inclusion of PII information for users. + * + * @covers ::execute + */ + public function test_sync_user_data() { + $this->resetAfterTest(); + [$course, $resource, $resource2, $resource3, $appreg] = $this->create_test_environment(); + $userrepo = new user_repository(); + + // Launch the tool for a user. + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'])[0]); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + + // Sync members. + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(range(1, 5), null, false, false)); + + ob_start(); + $task->execute(); + ob_end_clean(); + + // Verify 5 users and their corresponding course enrolments exist. + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(5, $ltiusers); + $this->verify_course_enrolments($course, $ltiusers); + + // Since user data wasn't included in the response, the users will have been synced using fallbacks, + // so verify these. + foreach ($ltiusers as $ltiuser) { + $user = \core_user::get_user($ltiuser->get_localid()); + // Firstname falls back to sourceid. + $this->assertEquals($ltiuser->get_sourceid(), $user->firstname); + + // Lastname falls back to resource context id. + $this->assertEquals($appreg->get_platformid(), $user->lastname); + + // Email falls back to example.com. + $issuersubhash = sha1($appreg->get_platformid() . '_' . $ltiuser->get_sourceid()); + $this->assertEquals("enrol_lti_13_{$issuersubhash}@example.com", $user->email); + } + + // Sync again, this time with user data included. + $mockmembers = $this->get_mock_members_with_ids(range(1, 5)); + $task = $this->get_mock_task_with_users($mockmembers); + + ob_start(); + $task->execute(); + ob_end_clean(); + + // User data was included in the response and should have been updated. + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(5, $ltiusers); + $this->verify_course_enrolments($course, $ltiusers); + foreach ($ltiusers as $ltiuser) { + $user = \core_user::get_user($ltiuser->get_localid()); + $mockmemberindex = array_search($ltiuser->get_sourceid(), array_column($mockmembers, 'user_id')); + $mockmember = $mockmembers[$mockmemberindex]; + $this->assertEquals($mockmember['given_name'], $user->firstname); + $this->assertEquals($mockmember['family_name'], $user->lastname); + $this->assertEquals($mockmember['email'], $user->email); + } + } + + /** + * Test verifying the task won't sync members for shared resources having member sync disabled. + * + * @covers ::execute + */ + public function test_membership_sync_disabled() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(true, true, false); + + // Launch the tool for a user. + $mockuser = $this->get_mock_launch_users_with_ids(['1'])[0]; + $mocklaunch = $this->get_mock_launch($resource, $mockuser); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + + // Sync members. + $task = $this->get_mock_task_with_users($this->get_mock_launch_users_with_ids(range(1, 4))); + ob_start(); + $task->execute(); + ob_end_clean(); + + // Verify no users were added or removed. + // A single user (the user who launched the resource link) is expected. + $userrepo = new user_repository(); + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(1, $ltiusers); + $this->assertEquals($mockuser['user_id'], $ltiusers[0]->get_sourceid()); + $this->verify_course_enrolments($course, $ltiusers); + } + + /** + * Test verifying the sync task for resources configured as 'helper::MEMBER_SYNC_ENROL_AND_UNENROL'. + * + * @covers ::execute + */ + public function test_sync_mode_enrol_and_unenrol() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + $userrepo = new user_repository(); + + // Launch the tool for a user. + $mockuser = $this->get_mock_launch_users_with_ids(['1'])[0]; + $mocklaunch = $this->get_mock_launch($resource, $mockuser); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + + // Sync members. + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(range(1, 3))); + + ob_start(); + $task->execute(); + ob_end_clean(); + + // Verify 3 users and their corresponding course enrolments exist. + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(3, $ltiusers); + $this->verify_course_enrolments($course, $ltiusers); + + // Now, simulate a subsequent sync in which 1 existing user maintains access, + // 2 existing users are unenrolled and 3 new users are enrolled. + $task2 = $this->get_mock_task_with_users($this->get_mock_members_with_ids(['1', '4', '5', '6'])); + ob_start(); + $task2->execute(); + ob_end_clean(); + + // Verify the missing users have been unenrolled and new users enrolled. + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(4, $ltiusers); + $unenrolleduserids = ['2', '3']; + $enrolleduserids = ['1', '4', '5', '6']; + foreach ($ltiusers as $ltiuser) { + $this->assertNotContains($ltiuser->get_sourceid(), $unenrolleduserids); + $this->assertContains($ltiuser->get_sourceid(), $enrolleduserids); + } + $this->verify_course_enrolments($course, $ltiusers); + } + + /** + * Confirm the sync task operation for resources configured as 'helper::MEMBER_SYNC_UNENROL_MISSING'. + * + * @covers ::execute + */ + public function test_sync_mode_unenrol_missing() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(true, true, true, helper::MEMBER_SYNC_UNENROL_MISSING); + $userrepo = new user_repository(); + + // Launch the tool for a user. + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids([1])[0]); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + $this->assertCount(1, $userrepo->find_by_resource($resource->id)); + + // Sync members using a payload which doesn't include the original launch user (User id = 1). + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(range(2, 3))); + + ob_start(); + $task->execute(); + ob_end_clean(); + + // Verify the original user (launching user) has been unenrolled and that no new members have been enrolled. + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(0, $ltiusers); + } + + /** + * Confirm the sync task operation for resources configured as 'helper::MEMBER_SYNC_ENROL_NEW'. + * + * @covers ::execute + */ + public function test_sync_mode_enrol_new() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(true, true, true, helper::MEMBER_SYNC_ENROL_NEW); + $userrepo = new user_repository(); + + // Launch the tool for a user. + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids([1])[0]); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + $this->assertCount(1, $userrepo->find_by_resource($resource->id)); + + // Sync members using a payload which includes two new members only (i.e. not the original launching user). + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(range(2, 3))); + + ob_start(); + $task->execute(); + ob_end_clean(); + + // Verify we now have 3 enrolments. The original user (who was not unenrolled) and the 2 new users. + $ltiusers = $userrepo->find_by_resource($resource->id); + $this->assertCount(3, $ltiusers); + $this->verify_course_enrolments($course, $ltiusers); + } + + /** + * Test confirming that no changes take place if the auth_lti plugin is not enabled. + * + * @covers ::execute + */ + public function test_sync_auth_disabled() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(false); + $userrepo = new user_repository(); + + // Launch the tool for a user. + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids([1])[0]); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + $this->assertCount(1, $userrepo->find_by_resource($resource->id)); + + // If the task were to run, this would trigger 1 unenrolment (the launching user) and 3 enrolments. + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(range(2, 2))); + $task->execute(); + + // Verify that the sync didn't take place. + $this->expectOutputRegex("/Skipping task - Authentication plugin 'LTI' is not enabled/"); + $this->assertCount(1, $userrepo->find_by_resource($resource->id)); + } + + /** + * Test confirming that no sync takes place when the enrol_lti plugin is not enabled. + * + * @covers ::execute + */ + public function test_sync_enrol_disabled() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(true, false); + $userrepo = new user_repository(); + + // Launch the tool for a user. + $mocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids([1])[0]); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + $this->assertCount(1, $userrepo->find_by_resource($resource->id)); + + // If the task were to run, this would trigger 1 unenrolment of the launching user and enrolment of 3 users. + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(range(2, 2))); + $task->execute(); + + // Verify that the sync didn't take place. + $this->expectOutputRegex("/Skipping task - The 'Publish as LTI tool' plugin is disabled/"); + $this->assertCount(1, $userrepo->find_by_resource($resource->id)); + } + + /** + * Test syncing members for a membersync-enabled resource when the launch omits the NRPS service endpoints. + * + * @covers ::execute + */ + public function test_sync_no_nrps_support() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + $userrepo = new user_repository(); + + // Launch the tool for a user. + $mockinstructor = $this->get_mock_launch_users_with_ids([1])[0]; + $mocklaunch = $this->get_mock_launch($resource, $mockinstructor, null, false, false); + $launchservice = $this->get_tool_launch_service(); + $instructoruser = $this->lti_advantage_user_authenticates('1'); + $launchservice->user_launches_tool($instructoruser, $mocklaunch); + $this->assertCount(1, $userrepo->find_by_resource($resource->id)); + + // The task would sync an additional 2 users if the link had NRPS service support. + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(range(2, 2))); + + // We expect the task to report that it is skipping the resource due to a lack of NRPS support. + $task->execute(); + + // Verify no enrolments or unenrolments. + $this->expectOutputRegex( + "/Skipping - No names and roles service found.\n". + "Completed - Synced members for tool '{$resource->id}' in the course '{$course->id}'. ". + "Processed 0 users; enrolled 0 members; unenrolled 0 members./" + ); + $this->assertCount(1, $userrepo->find_by_resource($resource->id)); + } + + /** + * Test confirming that preexisting, non-lti user accounts do not have their profiles or pictures updated during sync. + * + * @covers ::execute + */ + public function test_sync_non_lti_linked_user() { + $this->resetAfterTest(); + + // Set up the environment. + [$course, $resource] = $this->create_test_environment(); + + // Fake an auth - making sure it's a manual account. + $authenticateduser = $this->lti_advantage_user_authenticates('123'); + $authenticateduser->auth = 'manual'; + $authenticateduser->password = '1234abcD*'; + user_update_user($authenticateduser); + $authenticateduser = \core_user::get_user($authenticateduser->id); + + // Mock the launch for the specified user. + $mocklaunchuser = $this->get_mock_launch_users_with_ids([$authenticateduser->id])[0]; + $mocklaunch = $this->get_mock_launch($resource, $mocklaunchuser); + $this->get_tool_launch_service()->user_launches_tool($authenticateduser, $mocklaunch); + + // Prepare the sync task, with a stubbed list of members. + $task = $this->get_mock_task_with_users($this->get_mock_members_with_ids(['123'], null, true, true, true, true)); + + // Run the member sync. + $this->expectOutputRegex( + "/Skipped profile sync for user '$authenticateduser->id'. The user does not belong to the LTI auth method.\n" . + "Skipped picture sync for user '$authenticateduser->id'. The user does not belong to the LTI auth method/" + ); + $task->execute(); + + $updateduser = \core_user::get_user($authenticateduser->id); + $this->assertEquals($authenticateduser->firstname, $updateduser->firstname); + $this->assertEquals($authenticateduser->lastname, $updateduser->lastname); + $this->assertEquals($authenticateduser->email, $updateduser->email); + $this->verify_user_profile_image($authenticateduser->id, false); + } + + /** + * Test the member sync for a range of scenarios including migrated tools, unlaunched tools, provisioning methods. + * + * @dataProvider member_sync_data_provider + * @param array|null $legacydata array detailing what legacy information to create, or null if not required. + * @param array|null $resourceconfig array detailing config values to be used when creating the test enrol_lti instances. + * @param array $launchdata array containing details of the launch, including user and migration claim. + * @param array|null $syncmembers the members to use in the mock sync. + * @param array $expected the array detailing expectations. + * @covers ::execute + */ + public function test_sync_enrolments_and_migration(?array $legacydata, ?array $resourceconfig, array $launchdata, + ?array $syncmembers, array $expected) { + + $this->resetAfterTest(); + + // Set up the environment. + [$course, $resource] = $this->create_test_environment(true, true, true, helper::MEMBER_SYNC_ENROL_AND_UNENROL, true, false, + 0, $resourceconfig['provisioningmodeinstructor'] ?? 0, $resourceconfig['provisioningmodelearner'] ?? 0); + + // Set up legacy tool and user data. + if ($legacydata) { + [$legacytools, $legacyconsumerrecord, $legacyusers] = $this->setup_legacy_data($course, $legacydata); + } + + // Mock the launch for the specified user. + $mocklaunch = $this->get_mock_launch($resource, $launchdata['user'], null, true, true, + $launchdata['launch_migration_claim']); + + // Perform the launch. + $instructoruser = $this->lti_advantage_user_authenticates( + $launchdata['user']['user_id'], + $launchdata['launch_migration_claim'] ?? [] + ); + $this->get_tool_launch_service()->user_launches_tool($instructoruser, $mocklaunch); + + // Prepare the sync task, with a stubbed list of members. + $task = $this->get_mock_task_with_users($syncmembers); + + // Run the member sync. + ob_start(); + $task->execute(); + ob_end_clean(); + + // Verify enrolments. + $ltiusers = (new user_repository())->find_by_resource($resource->id); + $enrolled = array_filter($expected['enrolments'], function($user) { + return $user['is_enrolled']; + }); + $this->assertCount(count($enrolled), $ltiusers); + $this->verify_course_enrolments($course, $ltiusers); + + // Verify migration, if expected. + if ($legacydata) { + $legacyuserids = array_column($legacyusers, 'id'); + foreach ($ltiusers as $ltiuser) { + $this->assertArrayHasKey($ltiuser->get_sourceid(), $expected['enrolments']); + if (!$expected['enrolments'][$ltiuser->get_sourceid()]['is_migrated']) { + // Those members who hadn't launched over 1p1 prior will have new lti user records created. + $this->assertNotContains((string)$ltiuser->get_localid(), $legacyuserids); + } else { + // Those members who were either already migrated during launch, or were migrated during the sync, + // will be mapped to their legacy user accounts. + $this->assertContains((string)$ltiuser->get_localid(), $legacyuserids); + } + } + } + } + + /** + * Data provider for member syncs. + * + * @return array[] the array of test data. + */ + public function member_sync_data_provider(): array { + global $CFG; + require_once($CFG->dirroot . '/auth/lti/auth.php'); + return [ + 'Migrated tool, user ids changed, new and existing users present in sync' => [ + 'legacy_data' => [ + 'users' => [ + ['user_id' => '1'], + ['user_id' => '2'], + ], + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'resource_config' => null, + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => [ + 'consumer_key' => 'CONSUMER_1', + 'signing_secret' => 'toolsecret1', + 'user_id' => '1', + 'context_id' => 'd345b', + 'tool_consumer_instance_guid' => '12345-123', + 'resource_link_id' => '4b6fa' + ], + ], + 'sync_members_data' => [ + $this->get_mock_members_with_ids(['1p3_1'], ['1'])[0], + $this->get_mock_members_with_ids(['1p3_2'], ['2'])[0], + $this->get_mock_members_with_ids(['1p3_3'], ['3'])[0], + $this->get_mock_members_with_ids(['1p3_4'], ['4'])[0], + ], + 'expected' => [ + 'enrolments' => [ + '1p3_1' => [ + 'is_enrolled' => true, + 'is_migrated' => true, + ], + '1p3_2' => [ + 'is_enrolled' => true, + 'is_migrated' => true, + ], + '1p3_3' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '1p3_4' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ] + ] + ] + ], + 'Migrated tool, no change in user ids, new and existing users present in sync' => [ + 'legacy_data' => [ + 'users' => [ + ['user_id' => '1'], + ['user_id' => '2'], + ], + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'resource_config' => null, + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1'])[0], + 'launch_migration_claim' => [ + 'consumer_key' => 'CONSUMER_1', + 'signing_secret' => 'toolsecret1', + 'context_id' => 'd345b', + 'tool_consumer_instance_guid' => '12345-123', + 'resource_link_id' => '4b6fa' + ], + ], + 'sync_members_data' => [ + $this->get_mock_members_with_ids(['1'], null)[0], + $this->get_mock_members_with_ids(['2'], null)[0], + $this->get_mock_members_with_ids(['3'], null)[0], + $this->get_mock_members_with_ids(['4'], null)[0], + ], + 'expected' => [ + 'enrolments' => [ + '1' => [ + 'is_enrolled' => true, + 'is_migrated' => true, + ], + '2' => [ + 'is_enrolled' => true, + 'is_migrated' => true, + ], + '3' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '4' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ] + ] + ] + ], + 'New tool, no launch migration claim, change in user ids, new and existing users present in sync' => [ + 'legacy_data' => [ + 'users' => [ + ['user_id' => '1'], + ['user_id' => '2'], + ], + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'resource_config' => null, + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => null, + ], + 'sync_members_data' => [ + $this->get_mock_members_with_ids(['1p3_1'], null)[0], + $this->get_mock_members_with_ids(['1p3_2'], null)[0], + $this->get_mock_members_with_ids(['1p3_3'], null)[0], + $this->get_mock_members_with_ids(['1p3_4'], null)[0], + ], + 'expected' => [ + 'enrolments' => [ + '1p3_1' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '1p3_2' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '1p3_3' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '1p3_4' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ] + ] + ] + ], + 'New tool, no launch migration claim, no change in user ids, new and existing users present in sync' => [ + 'legacy_data' => [ + 'users' => [ + ['user_id' => '1'], + ['user_id' => '2'], + ], + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'resource_config' => null, + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1'])[0], + 'launch_migration_claim' => null, + ], + 'sync_members_data' => [ + $this->get_mock_members_with_ids(['1'], null)[0], + $this->get_mock_members_with_ids(['2'], null)[0], + $this->get_mock_members_with_ids(['3'], null)[0], + $this->get_mock_members_with_ids(['4'], null)[0], + ], + 'expected' => [ + 'enrolments' => [ + '1' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '2' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '3' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '4' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ] + ] + ] + ], + 'New tool, migration only via member sync, no launch claim, new and existing users present in sync' => [ + 'legacy_data' => [ + 'users' => [ + ['user_id' => '1'], + ['user_id' => '2'], + ], + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'resource_config' => null, + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => null, + ], + 'sync_members_data' => [ + $this->get_mock_members_with_ids(['1p3_1'], ['1'])[0], + $this->get_mock_members_with_ids(['1p3_2'], ['2'])[0], + $this->get_mock_members_with_ids(['1p3_3'], ['3'])[0], + $this->get_mock_members_with_ids(['1p3_4'], ['4'])[0], + ], + 'expected' => [ + 'enrolments' => [ + '1p3_1' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '1p3_2' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '1p3_3' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ], + '1p3_4' => [ + 'is_enrolled' => true, + 'is_migrated' => false, + ] + ] + ] + ], + 'Default provisioning modes, mixed bag of users and roles' => [ + 'legacy_data' => null, + 'resource_config' => [ + 'provisioningmodelearner' => \auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY, + 'provisioningmodeinstructor' => \auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => null, + ], + 'sync_members_data' => [ + // This user is just an instructor but is also the user who is already linked, via the launch above. + $this->get_mock_members_with_ids(['1p3_1'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + ])[0], + // This user is just a learner. + $this->get_mock_members_with_ids(['1p3_2'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is also a learner. + $this->get_mock_members_with_ids(['1p3_3'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is both an instructor and a learner. + $this->get_mock_members_with_ids(['1p3_4'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + ], + 'expected' => [ + 'enrolments' => [ + '1p3_1' => [ + 'is_enrolled' => true, // Instructor - enrolled because they are also the launch user (already linked). + 'is_migrated' => false, + ], + '1p3_2' => [ + 'is_enrolled' => true, // Learner - enrolled due to 'auto' provisioning mode. + 'is_migrated' => false, + ], + '1p3_3' => [ + 'is_enrolled' => true, // Learner - enrolled due to 'auto' provisioning mode. + 'is_migrated' => false, + ], + '1p3_4' => [ + 'is_enrolled' => false, // Both roles - not enrolled due to instructor's 'prompt' provisioning mode. + 'is_migrated' => false, + ] + ] + ] + ], + 'All automatic provisioning, mixed bag of users and roles' => [ + 'legacy_data' => null, + 'resource_config' => [ + 'provisioningmodelearner' => \auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY, + 'provisioningmodeinstructor' => \auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => null, + ], + 'sync_members_data' => [ + // This user is just an instructor but is also the user who is already linked, via the launch above. + $this->get_mock_members_with_ids(['1p3_1'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + ])[0], + // This user is just a learner. + $this->get_mock_members_with_ids(['1p3_2'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is also a learner. + $this->get_mock_members_with_ids(['1p3_3'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is both an instructor and a learner. + $this->get_mock_members_with_ids(['1p3_4'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + ], + 'expected' => [ + 'enrolments' => [ + '1p3_1' => [ + 'is_enrolled' => true, // Instructor - enrolled because they are also the launch user (already linked). + 'is_migrated' => false, + ], + '1p3_2' => [ + 'is_enrolled' => true, // Learner - enrolled due to 'auto' provisioning mode. + 'is_migrated' => false, + ], + '1p3_3' => [ + 'is_enrolled' => true, // Learner - enrolled due to 'auto' provisioning mode. + 'is_migrated' => false, + ], + '1p3_4' => [ + 'is_enrolled' => true, // Both roles - enrolled due to instructor's 'auto' provisioning mode. + 'is_migrated' => false, + ] + ] + ] + ], + 'All prompt provisioning, mixed bag of users and roles' => [ + 'legacy_data' => null, + 'resource_config' => [ + 'provisioningmodelearner' => \auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING, + 'provisioningmodeinstructor' => \auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => null, + ], + 'sync_members_data' => [ + // This user is just an instructor but is also the user who is already linked, via the launch above. + $this->get_mock_members_with_ids(['1p3_1'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + ])[0], + // This user is just a learner. + $this->get_mock_members_with_ids(['1p3_2'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is also a learner. + $this->get_mock_members_with_ids(['1p3_3'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is both an instructor and a learner. + $this->get_mock_members_with_ids(['1p3_4'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + ], + 'expected' => [ + 'enrolments' => [ + '1p3_1' => [ + 'is_enrolled' => true, // Instructor - enrolled because they are also the launch user (already linked). + 'is_migrated' => false, + ], + '1p3_2' => [ + 'is_enrolled' => false, // Learner - not enrolled due to 'prompt' provisioning mode. + 'is_migrated' => false, + ], + '1p3_3' => [ + 'is_enrolled' => false, // Learner - not enrolled due to 'prompt' provisioning mode. + 'is_migrated' => false, + ], + '1p3_4' => [ + 'is_enrolled' => false, // Both roles - not enrolled due to instructor's 'prompt' provisioning mode. + 'is_migrated' => false, + ] + ] + ] + ], + 'All automatic provisioning, with legacy data and migration claim, mixed bag of users and roles' => [ + 'legacy_data' => [ + 'users' => [ + ['user_id' => '2'], + ['user_id' => '3'], + ['user_id' => '4'], + ['user_id' => '5'] + ], + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'resource_config' => [ + 'provisioningmodelearner' => \auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY, + 'provisioningmodeinstructor' => \auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => [ + 'consumer_key' => 'CONSUMER_1', + 'signing_secret' => 'toolsecret1', + 'context_id' => 'd345b', + 'tool_consumer_instance_guid' => '12345-123', + 'resource_link_id' => '4b6fa' + ], + ], + 'sync_members_data' => [ + // This user is just an instructor but is also the user who is already linked, via the launch above. + $this->get_mock_members_with_ids(['1p3_1'], null, true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + ])[0], + // This user is just a learner. + $this->get_mock_members_with_ids(['1p3_2'], ['2'], true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is also a learner. + $this->get_mock_members_with_ids(['1p3_3'], ['3'], true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is both an instructor and a learner. + $this->get_mock_members_with_ids(['1p3_4'], ['4'], true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + ])[0], + // This user is just an instructor who hasn't launched before (unlike the first user here). + $this->get_mock_members_with_ids(['1p3_5'], ['5'], true, true, true, false, [ + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', + ])[0], + ], + 'expected' => [ + 'enrolments' => [ + '1p3_1' => [ + 'is_enrolled' => true, // Instructor - enrolled because they are also the launch user (already linked). + 'is_migrated' => false, + ], + '1p3_2' => [ + 'is_enrolled' => true, // Learner - enrolled due to 'auto' provisioning mode. + 'is_migrated' => true, + ], + '1p3_3' => [ + 'is_enrolled' => true, // Learner - enrolled due to 'auto' provisioning mode. + 'is_migrated' => true, + ], + '1p3_4' => [ + 'is_enrolled' => true, // Both roles - enrolled due to instructor's 'auto' provisioning mode. + 'is_migrated' => true + ], + '1p3_5' => [ + 'is_enrolled' => true, // Instructor role only - enrolled due to instructor's 'auto' provisioning mode. + 'is_migrated' => true + ] + ] + ] + ], + ]; + } +} diff --git a/enrol/lti/version.php b/enrol/lti/version.php index 3b633f35ef8..fd2c9750473 100644 --- a/enrol/lti/version.php +++ b/enrol/lti/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2021052514; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2021052515; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2021052500; // Requires this Moodle version. $plugin->component = 'enrol_lti'; // Full name of the plugin (used for diagnostics). $plugin->dependencies = [ -- 2.11.4.GIT