diff --git a/enrol/lti/classes/local/ltiadvantage/service/tool_launch_service.php b/enrol/lti/classes/local/ltiadvantage/service/tool_launch_service.php new file mode 100644 index 00000000000..c390865e7c7 --- /dev/null +++ b/enrol/lti/classes/local/ltiadvantage/service/tool_launch_service.php @@ -0,0 +1,384 @@ +. + +namespace enrol_lti\local\ltiadvantage\service; + +use enrol_lti\helper; +use enrol_lti\local\ltiadvantage\entity\context; +use enrol_lti\local\ltiadvantage\entity\deployment; +use enrol_lti\local\ltiadvantage\entity\migration_claim; +use enrol_lti\local\ltiadvantage\entity\resource_link; +use enrol_lti\local\ltiadvantage\entity\user; +use enrol_lti\local\ltiadvantage\repository\application_registration_repository; +use enrol_lti\local\ltiadvantage\repository\context_repository; +use enrol_lti\local\ltiadvantage\repository\deployment_repository; +use enrol_lti\local\ltiadvantage\repository\legacy_consumer_repository; +use enrol_lti\local\ltiadvantage\repository\resource_link_repository; +use enrol_lti\local\ltiadvantage\repository\user_repository; +use Packback\Lti1p3\LtiMessageLaunch; + +/** + * Class tool_launch_service. + * + * This class handles the launch of a resource by a user, using the LTI Advantage Resource Link Launch. + * + * See http://www.imsglobal.org/spec/lti/v1p3/#launch-from-a-resource-link + * + * @package enrol_lti + * @copyright 2021 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class tool_launch_service { + + /** @var deployment_repository $deploymentrepo instance of a deployment repository. */ + private $deploymentrepo; + + /** @var application_registration_repository instance of a application_registration repository */ + private $registrationrepo; + + /** @var resource_link_repository instance of a resource_link repository */ + private $resourcelinkrepo; + + /** @var user_repository instance of a user repository*/ + private $userrepo; + + /** @var context_repository instance of a context repository */ + private $contextrepo; + + /** + * The tool_launch_service constructor. + * + * @param deployment_repository $deploymentrepo instance of a deployment_repository. + * @param application_registration_repository $registrationrepo instance of an application_registration_repository. + * @param resource_link_repository $resourcelinkrepo instance of a resource_link_repository. + * @param user_repository $userrepo instance of a user_repository. + * @param context_repository $contextrepo instance of a context_repository. + */ + public function __construct(deployment_repository $deploymentrepo, + application_registration_repository $registrationrepo, resource_link_repository $resourcelinkrepo, + user_repository $userrepo, context_repository $contextrepo) { + + $this->deploymentrepo = $deploymentrepo; + $this->registrationrepo = $registrationrepo; + $this->resourcelinkrepo = $resourcelinkrepo; + $this->userrepo = $userrepo; + $this->contextrepo = $contextrepo; + } + + /** Get the launch data from the launch. + * + * @param LtiMessageLaunch $launch the launch instance. + * @return \stdClass the launch data. + */ + private function get_launch_data(LtiMessageLaunch $launch): \stdClass { + $launchdata = $launch->getLaunchData(); + $data = [ + 'platform' => $launchdata['iss'], + 'clientid' => $launchdata['aud'], // See LTI_Message_Launch::validate_registration for details about aud. + 'exp' => $launchdata['exp'], + 'nonce' => $launchdata['nonce'], + 'sub' => $launchdata['sub'], + 'roles' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/roles'], + 'deploymentid' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/deployment_id'], + 'context' => !empty($launchdata['https://purl.imsglobal.org/spec/lti/claim/context']) ? + $launchdata['https://purl.imsglobal.org/spec/lti/claim/context'] : null, + 'resourcelink' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/resource_link'], + 'targetlinkuri' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/target_link_uri'], + 'custom' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/custom'] ?? null, + 'launchid' => $launch->getLaunchId(), + 'user' => [ + 'givenname' => !empty($launchdata['given_name']) ? $launchdata['given_name'] : null, + 'familyname' => !empty($launchdata['family_name']) ? $launchdata['family_name'] : null, + 'name' => !empty($launchdata['name']) ? $launchdata['name'] : null, + 'email' => !empty($launchdata['email']) ? $launchdata['email'] : null, + 'picture' => !empty($launchdata['picture']) ? $launchdata['picture'] : null, + ], + 'ags' => $launchdata['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'] ?? null, + 'nrps' => $launchdata['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice'] ?? null, + 'lti1p1' => $launchdata['https://purl.imsglobal.org/spec/lti/claim/lti1p1'] ?? null + ]; + + return (object) $data; + } + + /** + * Get a context instance from the launch data. + * + * @param \stdClass $launchdata launch data. + * @param deployment $deployment the deployment to which the context belongs. + * @return context the context instance. + */ + private function context_from_launchdata(\stdClass $launchdata, deployment $deployment): context { + if ($context = $this->contextrepo->find_by_contextid($launchdata->context['id'], $deployment->get_id())) { + // The context has been mapped, just update it. + $context->set_types($launchdata->context['type']); + } else { + // Map a new context. + $context = $deployment->add_context($launchdata->context['id'], $launchdata->context['type']); + } + return $context; + } + + /** + * Get a resource_link from the launch data. + * + * @param \stdClass $launchdata the launch data. + * @param \stdClass $resource the resource to which the resource link refers. + * @param deployment $deployment the deployment to which the resource_link belongs. + * @param context|null $context optional context in which the resource_link lives, null if not needed. + * @return resource_link the resource_link instance. + */ + private function resource_link_from_launchdata(\stdClass $launchdata, \stdClass $resource, deployment $deployment, + ?context $context): resource_link { + + if ($resourcelink = $this->resourcelinkrepo->find_by_deployment($deployment, $launchdata->resourcelink['id'])) { + // Resource link exists, so update it. + if (isset($context)) { + $resourcelink->set_contextid($context->get_id()); + } + // A resource link may have been updated, via content item selection, to refer to a different resource. + if ($resourcelink->get_resourceid() != $resource->id) { + $resourcelink->set_resourceid($resource->id); + } + } else { + // Create a new resource link. + $resourcelink = $deployment->add_resource_link( + $launchdata->resourcelink['id'], + $resource->id, + $context ? $context->get_id() : null + ); + } + // AGS. If the lineitemsurl is missing, it means the tool has no access to the endpoint. + // See: http://www.imsglobal.org/spec/lti-ags/v2p0#assignment-and-grade-service-claim. + if ($launchdata->ags && $launchdata->ags['lineitems']) { + $resourcelink->add_grade_service( + new \moodle_url($launchdata->ags['lineitems']), + isset($launchdata->ags['lineitem']) ? new \moodle_url($launchdata->ags['lineitem']) : null, + $launchdata->ags['scope'] + ); + } + + // NRPS. + if ($launchdata->nrps) { + $resourcelink->add_names_and_roles_service( + new \moodle_url($launchdata->nrps['context_memberships_url']), + $launchdata->nrps['service_versions'] + ); + } + return $resourcelink; + } + + /** + * Get an lti user instance from the launch data. + * + * @param \stdClass $user the moodle user object. + * @param \stdClass $launchdata the launch data. + * @param \stdClass $resource the resource to which the user belongs. + * @param resource_link $resourcelink the resource_link from which the user originated. + * @return user the user instance. + */ + private function lti_user_from_launchdata(\stdClass $user, \stdClass $launchdata, \stdClass $resource, + resource_link $resourcelink): user { + + // Find the user based on the unique-to-the-issuer 'sub' value. + if ($ltiuser = $this->userrepo->find_single_user_by_resource($user->id, $resource->id)) { + // User exists, so update existing based on resource data which may have changed. + $ltiuser->set_resourcelinkid($resourcelink->get_id()); + $ltiuser->set_lang($resource->lang); + $ltiuser->set_city($resource->city); + $ltiuser->set_country($resource->country); + $ltiuser->set_institution($resource->institution); + $ltiuser->set_timezone($resource->timezone); + $ltiuser->set_maildisplay($resource->maildisplay); + } else { + // Create the lti user. + $ltiuser = $resourcelink->add_user( + $user->id, + $launchdata->sub, + $resource->lang, + $resource->city ?? '', + $resource->country ?? '', + $resource->institution ?? '', + $resource->timezone ?? '', + $resource->maildisplay ?? null, + ); + } + $ltiuser->set_lastaccess(time()); + return $ltiuser; + } + + /** + * Get the migration claim from the launch data, or null if not found. + * + * @param \stdClass $launchdata the launch data. + * @return migration_claim|null the claim instance if present in the launch data, else null. + */ + private function migration_claim_from_launchdata(\stdClass $launchdata): ?migration_claim { + if (!isset($launchdata->lti1p1)) { + return null; + } + + // Despite the spec requiring the oauth_consumer_key field be present in the migration claim: + // (see https://www.imsglobal.org/spec/lti/v1p3/migr#oauth_consumer_key), + // Platforms may omit this field making migration impossible. + // E.g. for Canvas launches taking place after an assignment_selection placement. + if (empty($launchdata->lti1p1['oauth_consumer_key'])) { + return null; + } + + return new migration_claim($launchdata->lti1p1, $launchdata->deploymentid, + $launchdata->platform, $launchdata->clientid, $launchdata->exp, $launchdata->nonce, + new legacy_consumer_repository()); + } + + /** + * Check whether the launch user has an admin role. + * + * @param \stdClass $launchdata the launch data. + * @return bool true if the user is admin, false otherwise. + */ + private function user_is_admin(\stdClass $launchdata): bool { + // See: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies. + if ($launchdata->roles) { + $adminroles = [ + 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator', + 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator' + ]; + + foreach ($adminroles as $validrole) { + if (in_array($validrole, $launchdata->roles)) { + return true; + } + } + } + return false; + } + + /** + * Check whether the launch user is an instructor. + * + * @param \stdClass $launchdata the launch data. + * @param bool $includelegacy whether to also consider legacy simple names as valid roles. + * @return bool true if the user is an instructor, false otherwise. + */ + private function user_is_staff(\stdClass $launchdata, bool $includelegacy = false): bool { + // See: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies. + // This method also provides support for (legacy, deprecated) simple names for context roles. + // I.e. 'ContentDeveloper' may be supported. + if ($launchdata->roles) { + $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' + ]; + + if ($includelegacy) { + $staffroles[] = 'ContentDeveloper'; + $staffroles[] = 'Instructor'; + $staffroles[] = 'Instructor#TeachingAssistant'; + } + + foreach ($staffroles as $validrole) { + if (in_array($validrole, $launchdata->roles)) { + return true; + } + } + } + return false; + } + + /** + * Handles the use case "A user launches the tool so they can view an external resource". + * + * @param \stdClass $user the Moodle user record, obtained via the auth_lti authentication process. + * @param LtiMessageLaunch $launch the launch data. + * @return array array containing [int $userid, \stdClass $resource] + * @throws \moodle_exception if launch problems are encountered. + */ + public function user_launches_tool(\stdClass $user, LtiMessageLaunch $launch): array { + + $launchdata = $this->get_launch_data($launch); + + if (!$registration = $this->registrationrepo->find_by_platform($launchdata->platform, $launchdata->clientid)) { + throw new \moodle_exception('ltiadvlauncherror:invalidregistration', 'enrol_lti', '', + [$launchdata->platform, $launchdata->clientid]); + } + + if (!$deployment = $this->deploymentrepo->find_by_registration($registration->get_id(), + $launchdata->deploymentid)) { + throw new \moodle_exception('ltiadvlauncherror:invaliddeployment', 'enrol_lti', '', + [$launchdata->deploymentid]); + } + + $resourceuuid = $launchdata->custom['id'] ?? null; + if (empty($resourceuuid)) { + throw new \moodle_exception('ltiadvlauncherror:missingid', 'enrol_lti'); + } + + $resource = array_values(helper::get_lti_tools(['uuid' => $resourceuuid])); + $resource = $resource[0] ?? null; + if (empty($resource) || $resource->status != ENROL_INSTANCE_ENABLED) { + throw new \moodle_exception('ltiadvlauncherror:invalidid', 'enrol_lti', '', $resourceuuid); + } + + // Update the deployment with the legacy consumer_key information, allowing migration of users to take place in future + // names and roles syncs. + if ($migrationclaim = $this->migration_claim_from_launchdata($launchdata)) { + $deployment->set_legacy_consumer_key($migrationclaim->get_consumer_key()); + $this->deploymentrepo->save($deployment); + } + + // Save the context, if that claim is present. + $context = null; + if ($launchdata->context) { + $context = $this->context_from_launchdata($launchdata, $deployment); + $context = $this->contextrepo->save($context); + } + + // Save the resource link for the tool deployment. + $resourcelink = $this->resource_link_from_launchdata($launchdata, $resource, $deployment, $context); + $resourcelink = $this->resourcelinkrepo->save($resourcelink); + + // Save the user launching the resource link. + $ltiuser = $this->lti_user_from_launchdata($user, $launchdata, $resource, $resourcelink); + $ltiuser = $this->userrepo->save($ltiuser); + + // Set the frame embedding mode, which controls the display of blocks and nav when launching. + global $SESSION; + $context = \context::instance_by_id($resource->contextid); + $isforceembed = $launchdata->custom['force_embed'] ?? false; + $isinstructor = $this->user_is_staff($launchdata, true) || $this->user_is_admin($launchdata); + $isforceembed = $isforceembed || ($context->contextlevel == CONTEXT_MODULE && !$isinstructor); + if ($isforceembed) { + $SESSION->forcepagelayout = 'embedded'; + } else { + unset($SESSION->forcepagelayout); + } + + // Enrol the user in the course with no role. + $result = helper::enrol_user($resource, $ltiuser->get_localid()); + if ($result !== helper::ENROLMENT_SUCCESSFUL) { + throw new \moodle_exception($result, 'enrol_lti'); + } + + // Give the user the role in the given context. + $roleid = $isinstructor ? $resource->roleinstructor : $resource->rolelearner; + role_assign($roleid, $ltiuser->get_localid(), $resource->contextid); + + return [$ltiuser->get_localid(), $resource]; + } + +} diff --git a/enrol/lti/tests/local/ltiadvantage/service/tool_launch_service_test.php b/enrol/lti/tests/local/ltiadvantage/service/tool_launch_service_test.php new file mode 100644 index 00000000000..d897859d18c --- /dev/null +++ b/enrol/lti/tests/local/ltiadvantage/service/tool_launch_service_test.php @@ -0,0 +1,523 @@ +. + +namespace enrol_lti\local\ltiadvantage\service; + +use core_availability\info_module; +use enrol_lti\local\ltiadvantage\entity\resource_link; +use enrol_lti\local\ltiadvantage\entity\user; +use enrol_lti\local\ltiadvantage\entity\context; +use enrol_lti\local\ltiadvantage\repository\application_registration_repository; +use enrol_lti\local\ltiadvantage\repository\context_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; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../lti_advantage_testcase.php'); + +/** + * Tests for the tool_launch_service. + * + * @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\service\tool_launch_service + */ +class tool_launch_service_test extends \lti_advantage_testcase { + + /** + * Test the use case "A user launches a tool so they can view an external resource/activity". + * + * @dataProvider user_launch_provider + * @param array|null $legacydata array detailing what legacy information to create, or null if not required. + * @param array|null $launchdata array containing details of the launch, including user and migration claim. + * @param array $expected the array detailing expectations. + * @covers ::user_launches_tool + */ + public function test_user_launches_tool(?array $legacydata, ?array $launchdata, array $expected) { + $this->resetAfterTest(); + // Setup. + $contextrepo = new context_repository(); + $resourcelinkrepo = new resource_link_repository(); + $deploymentrepo = new deployment_repository(); + $userrepo = new user_repository(); + [ + $course, + $modresource, + $modresource2, + $courseresource, + $registration, + $deployment + ] = $this->create_test_environment(); + $instructoruser = $this->getDataGenerator()->create_user(); + + // Generate the legacy data, on which the user migration is based. + if ($legacydata) { + [$legacytools, $legacyconsumer, $legacyusers] = $this->setup_legacy_data($course, $legacydata); + } + + // Get a mock 1.3 launch, optionally including the lti1p1 migration claim based on a legacy tool secret. + $mocklaunch = $this->get_mock_launch($modresource, $launchdata['user'], null, true, true, + $launchdata['launch_migration_claim']); + + // Call the service. + $launchservice = $this->get_tool_launch_service(); + if (isset($expected['exception'])) { + $this->expectException($expected['exception']); + $this->expectExceptionMessage($expected['exception_message']); + } + [$userid, $resource] = $launchservice->user_launches_tool($instructoruser, $mocklaunch); + + // As part of the launch, we expect to now have an lti-enrolled user who is recorded against the deployment. + $users = $userrepo->find_by_resource($resource->id); + $this->assertCount(1, $users); + $user = array_pop($users); + $this->assertInstanceOf(user::class, $user); + $this->assertEquals($deployment->get_id(), $user->get_deploymentid()); + + // Deployment should be mapped to the legacy consumer key even if the user wasn't matched and migrated. + $updateddeployment = $deploymentrepo->find($deployment->get_id()); + $this->assertEquals($expected['deployment_consumer_key'], $updateddeployment->get_legacy_consumer_key()); + + // The user comes from a resource_link, details of which should also be saved and linked to the deployment. + $resourcelinks = $resourcelinkrepo->find_by_resource_and_user($resource->id, $user->get_id()); + $this->assertCount(1, $resourcelinks); + $resourcelink = array_pop($resourcelinks); + $this->assertInstanceOf(resource_link::class, $resourcelink); + $this->assertEquals($deployment->get_id(), $resourcelink->get_deploymentid()); + + // The resourcelink should have a context, which should also be saved and linked to the deployment. + $context = $contextrepo->find($resourcelink->get_contextid()); + $this->assertInstanceOf(context::class, $context); + $this->assertEquals($deployment->get_id(), $context->get_deploymentid()); + + $enrolledusers = get_enrolled_users(\context_course::instance($course->id)); + $this->assertCount(1, $enrolledusers); + + // Verify the module is visible to the user. + $cmcontext = \context::instance_by_id($modresource->contextid); + $this->assertTrue(info_module::is_user_visible($cmcontext->instanceid, $userid)); + + // And that other published modules are not yet visible to the user. + $cmcontext = \context::instance_by_id($modresource2->contextid); + $this->assertFalse(info_module::is_user_visible($cmcontext->instanceid, $userid)); + } + + /** + * Provider for user launch testing. + * + * @return array the test case data. + */ + public function user_launch_provider(): array { + return [ + 'New tool: no legacy data, no migration claim sent' => [ + 'legacy_data' => null, + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => null, + ], + 'expected' => [ + 'deployment_consumer_key' => null, + ] + ], + 'Migrated tool: Legacy data exists, no change in user_id so omitted from claim' => [ + 'legacy_data' => [ + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + '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' + ], + ], + 'expected' => [ + 'deployment_consumer_key' => 'CONSUMER_1', + ] + ], + + 'Migrated tool: Legacy data exists, platform signs with different valid secret' => [ + 'legacy_data' => [ + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => [ + 'consumer_key' => 'CONSUMER_1', + 'signing_secret' => 'toolsecret2', + 'context_id' => 'd345b', + 'tool_consumer_instance_guid' => '12345-123', + 'resource_link_id' => '4b6fa' + ], + ], + 'expected' => [ + 'deployment_consumer_key' => 'CONSUMER_1', + ] + ], + 'Migrated tool: Legacy data exists, no migration claim sent' => [ + 'legacy_data' => [ + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => null, + ], + 'expected' => [ + 'deployment_consumer_key' => null, + ] + ], + 'Migrated tool: Legacy data exists, migration claim signature generated using invalid secret' => [ + 'legacy_data' => [ + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => [ + 'consumer_key' => 'CONSUMER_1', + 'signing_secret' => 'secret-not-mapped-to-consumer', + 'user_id' => 'user-id-123', + 'context_id' => 'd345b', + 'tool_consumer_instance_guid' => '12345-123', + 'resource_link_id' => '4b6fa' + ], + ], + 'expected' => [ + 'exception' => \coding_exception::class, + 'exception_message' => "Invalid 'oauth_consumer_key_sign' signature in lti1p1 claim" + ] + ], + 'Migrated tool: Legacy data exists, migration claim signature omitted' => [ + 'legacy_data' => [ + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => [ + 'consumer_key' => 'CONSUMER_1', + 'user_id' => 'user-id-123', + 'context_id' => 'd345b', + 'tool_consumer_instance_guid' => '12345-123', + 'resource_link_id' => '4b6fa' + ], + ], + 'expected' => [ + 'exception' => \coding_exception::class, + 'exception_message' => "Missing 'oauth_consumer_key_sign' property in lti1p1 migration claim." + ] + ], + 'Migrated tool: Legacy data exists, migration claim missing oauth_consumer_key' => [ + 'legacy_data' => [ + 'consumer_key' => 'CONSUMER_1', + 'tools' => [ + ['secret' => 'toolsecret1'], + ['secret' => 'toolsecret2'], + ] + ], + 'launch_data' => [ + 'user' => $this->get_mock_launch_users_with_ids(['1p3_1'])[0], + 'launch_migration_claim' => [ + 'user_id' => 'user-id-123', + 'context_id' => 'd345b', + 'tool_consumer_instance_guid' => '12345-123', + 'resource_link_id' => '4b6fa' + ], + ], + 'expected' => [ + 'deployment_consumer_key' => null + ] + ] + ]; + } + + /** + * Test confirming that an exception is thrown if trying to launch a published resource without a custom id. + * + * @covers ::user_launches_tool + */ + public function test_user_launches_tool_missing_custom_id() { + $this->resetAfterTest(); + [$course, $modresource] = $this->create_test_environment(); + $instructoruser = $this->getDataGenerator()->create_user(); + $launchservice = $this->get_tool_launch_service(); + $mockuser = $this->get_mock_launch_users_with_ids(['1p3_1'])[0]; + $mocklaunch = $this->get_mock_launch($modresource, $mockuser, null, false, false, null, []); + + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessage(get_string('ltiadvlauncherror:missingid', 'enrol_lti')); + [$userid, $resource] = $launchservice->user_launches_tool($instructoruser, $mocklaunch); + } + + /** + * Test confirming that an exception is thrown if trying to launch a published resource that doesn't exist. + * + * @covers ::user_launches_tool + */ + public function test_user_launches_tool_invalid_custom_id() { + $this->resetAfterTest(); + [$course, $modresource] = $this->create_test_environment(); + $instructoruser = $this->getDataGenerator()->create_user(); + $launchservice = $this->get_tool_launch_service(); + $mockuser = $this->get_mock_launch_users_with_ids(['1p3_1'])[0]; + $mocklaunch = $this->get_mock_launch($modresource, $mockuser, null, false, false, null, ['id' => 999999]); + + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessage(get_string('ltiadvlauncherror:invalidid', 'enrol_lti', 999999)); + [$userid, $resource] = $launchservice->user_launches_tool($instructoruser, $mocklaunch); + } + + /** + * Test confirming that an exception is thrown if trying to launch the tool where no application can be found. + * + * @covers ::user_launches_tool + */ + public function test_user_launches_tool_missing_registration() { + $this->resetAfterTest(); + // Setup. + [ + $course, + $modresource, + $modresource2, + $courseresource, + $registration, + $deployment + ] = $this->create_test_environment(); + $instructoruser = $this->getDataGenerator()->create_user(); + + // Delete the registration before trying to launch. + $appregrepo = new application_registration_repository(); + $appregrepo->delete($registration->get_id()); + + // Call the service. + $launchservice = $this->get_tool_launch_service(); + $mockuser = $this->get_mock_launch_users_with_ids(['1p3_1'])[0]; + $mocklaunch = $this->get_mock_launch($modresource, $mockuser); + + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessage(get_string('ltiadvlauncherror:invalidregistration', 'enrol_lti', + [$registration->get_platformid(), $registration->get_clientid()])); + [$userid, $resource] = $launchservice->user_launches_tool($instructoruser, $mocklaunch); + } + + /** + * Test confirming that an exception is thrown if trying to launch the tool where no deployment can be found. + * + * @covers ::user_launches_tool + */ + public function test_user_launches_tool_missing_deployment() { + $this->resetAfterTest(); + // Setup. + [ + $course, + $modresource, + $modresource2, + $courseresource, + $registration, + $deployment + ] = $this->create_test_environment(); + $instructoruser = $this->getDataGenerator()->create_user(); + + // Delete the deployment before trying to launch. + $deploymentrepo = new deployment_repository(); + $deploymentrepo->delete($deployment->get_id()); + + // Call the service. + $launchservice = $this->get_tool_launch_service(); + $mockuser = $this->get_mock_launch_users_with_ids(['1p3_1'])[0]; + $mocklaunch = $this->get_mock_launch($modresource, $mockuser); + + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessage(get_string('ltiadvlauncherror:invaliddeployment', 'enrol_lti', + [$deployment->get_deploymentid()])); + [$userid, $resource] = $launchservice->user_launches_tool($instructoruser, $mocklaunch); + } + + /** + * Test the mapping from IMS roles to Moodle roles during a launch. + * + * @covers ::user_launches_tool + */ + public function test_user_launches_tool_role_mapping() { + $this->resetAfterTest(); + // Create mock launches for 3 different user types: instructor, admin, learner. + [$course, $modresource] = $this->create_test_environment(); + $instructoruser = $this->getDataGenerator()->create_user(); + $instructor2user = $this->getDataGenerator()->create_user(); + $adminuser = $this->getDataGenerator()->create_user(); + $learneruser = $this->getDataGenerator()->create_user(); + $mockinstructoruser = $this->get_mock_launch_users_with_ids(['1'])[0]; + $mockadminuser = $this->get_mock_launch_users_with_ids( + ['2'], + false, + 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator' + )[0]; + $mocklearneruser = $this->get_mock_launch_users_with_ids( + ['3'], + false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' + )[0]; + $mockinstructor2user = $this->get_mock_launch_users_with_ids( + ['3'], + false, + 'Instructor' // Using the legacy (deprecated in 1.3) simple name. + )[0]; + $mockinstructorlaunch = $this->get_mock_launch($modresource, $mockinstructoruser); + $mockadminlaunch = $this->get_mock_launch($modresource, $mockadminuser); + $mocklearnerlaunch = $this->get_mock_launch($modresource, $mocklearneruser); + $mockinstructor2launch = $this->get_mock_launch($modresource, $mockinstructor2user); + + // Launch and confirm the role assignment. + $launchservice = $this->get_tool_launch_service(); + $modulecontext = \context::instance_by_id($modresource->contextid); + + [$instructorid] = $launchservice->user_launches_tool($instructoruser, $mockinstructorlaunch); + [$instructorrole] = array_slice(get_user_roles($modulecontext, $instructorid), 0, 1); + $this->assertEquals('teacher', $instructorrole->shortname); + + [$adminid] = $launchservice->user_launches_tool($adminuser, $mockadminlaunch); + [$adminrole] = array_slice(get_user_roles($modulecontext, $adminid), 0, 1); + $this->assertEquals('teacher', $adminrole->shortname); + + [$learnerid] = $launchservice->user_launches_tool($learneruser, $mocklearnerlaunch); + [$learnerrole] = array_slice(get_user_roles($modulecontext, $learnerid), 0, 1); + $this->assertEquals('student', $learnerrole->shortname); + + [$instructor2id] = $launchservice->user_launches_tool($instructor2user, $mockinstructor2launch); + [$instructor2role] = array_slice(get_user_roles($modulecontext, $instructor2id), 0, 1); + $this->assertEquals('teacher', $instructor2role->shortname); + } + + /** + * Test verifying that a user launch can result in updates to some user fields. + * + * @covers ::user_launches_tool + */ + public function test_user_launches_tool_user_fields_updated() { + $this->resetAfterTest(); + [$course, $modresource] = $this->create_test_environment(); + $user = $this->getDataGenerator()->create_user(); + $mockinstructoruser = $this->get_mock_launch_users_with_ids(['1'])[0]; + $launchservice = $this->get_tool_launch_service(); + $userrepo = new user_repository(); + + // Launch once, verifying the user details. + $mocklaunch = $this->get_mock_launch($modresource, $mockinstructoruser); + $launchservice->user_launches_tool($user, $mocklaunch); + $createduser = $userrepo->find_single_user_by_resource( + $user->id, + $modresource->id + ); + $this->assertEquals($modresource->lang, $createduser->get_lang()); + $this->assertEquals($modresource->city, $createduser->get_city()); + $this->assertEquals($modresource->country, $createduser->get_country()); + $this->assertEquals($modresource->institution, $createduser->get_institution()); + $this->assertEquals($modresource->timezone, $createduser->get_timezone()); + $this->assertEquals($modresource->maildisplay, $createduser->get_maildisplay()); + + // Change the resource's defaults and relaunch, verifying the relevant fields are updated for the launch user. + // Note: lang change can't be tested without installation of another language pack. + $modresource->city = 'Paris'; + $modresource->country = 'FR'; + $modresource->institution = 'Updated institution name'; + $modresource->timezone = 'UTC'; + $modresource->maildisplay = '1'; + global $DB; + $DB->update_record('enrol_lti_tools', $modresource); + + $mocklaunch = $this->get_mock_launch($modresource, $mockinstructoruser); + $launchservice->user_launches_tool($user, $mocklaunch); + $createduser = $userrepo->find($createduser->get_id()); + $this->assertEquals($modresource->city, $createduser->get_city()); + $this->assertEquals($modresource->country, $createduser->get_country()); + $this->assertEquals($modresource->institution, $createduser->get_institution()); + $this->assertEquals($modresource->timezone, $createduser->get_timezone()); + $this->assertEquals($modresource->maildisplay, $createduser->get_maildisplay()); + } + + /** + * Test the launch when a module has an enrolment start date. + * + * @covers ::user_launches_tool + */ + public function test_user_launches_tool_max_enrolment_start_restriction() { + $this->resetAfterTest(); + [$course, $modresource] = $this->create_test_environment(true, true, false, + \enrol_lti\helper::MEMBER_SYNC_ENROL_NEW, false, false, time() + DAYSECS); + $instructoruser = $this->getDataGenerator()->create_user(); + $mockinstructoruser = $this->get_mock_launch_users_with_ids(['1'])[0]; + $mockinstructorlaunch = $this->get_mock_launch($modresource, $mockinstructoruser); + $launchservice = $this->get_tool_launch_service(); + + $this->expectException(\moodle_exception::class); + $launchservice->user_launches_tool($instructoruser, $mockinstructorlaunch); + } + + /** + * Test the Moodle-specific custom param 'forceembed' during user launches. + * + * @covers ::user_launches_tool + */ + public function test_user_launches_tool_force_embedding_custom_param() { + $this->resetAfterTest(); + [$course, $modresource] = $this->create_test_environment(); + $instructoruser = $this->getDataGenerator()->create_user(); + $learneruser = $this->getDataGenerator()->create_user(); + $mockinstructoruser = $this->get_mock_launch_users_with_ids(['1'])[0]; + $mocklearneruser = $this->get_mock_launch_users_with_ids(['1'], false, '')[0]; + $mockinstructorlaunch = $this->get_mock_launch($modresource, $mockinstructoruser, null, false, false, null, [ + 'id' => $modresource->uuid, + 'forcedembed' => true + ]); + $mocklearnerlaunch = $this->get_mock_launch($modresource, $mocklearneruser, null, false, false, null, [ + 'id' => $modresource->uuid, + 'forcedembed' => true + ]); + $launchservice = $this->get_tool_launch_service(); + global $SESSION; + + // Instructors aren't subject to forceembed. + $launchservice->user_launches_tool($instructoruser, $mockinstructorlaunch); + $this->assertObjectNotHasAttribute('forcepagelayout', $SESSION); + + // Learners are. + $launchservice->user_launches_tool($learneruser, $mocklearnerlaunch); + $this->assertEquals('embedded', $SESSION->forcepagelayout); + } +}