diff --git a/admin/tool/dataprivacy/classes/api.php b/admin/tool/dataprivacy/classes/api.php index f626371ecf1..8b2142f6530 100644 --- a/admin/tool/dataprivacy/classes/api.php +++ b/admin/tool/dataprivacy/classes/api.php @@ -988,6 +988,7 @@ class api { */ public static function add_request_contexts_with_status(contextlist_collection $clcollection, int $requestid, int $status) { $request = new data_request($requestid); + $user = \core_user::get_user($request->get('userid')); foreach ($clcollection as $contextlist) { // Convert the \core_privacy\local\request\contextlist into a contextlist persistent and store it. $clp = \tool_dataprivacy\contextlist::from_contextlist($contextlist); @@ -998,10 +999,14 @@ class api { foreach ($contextlist->get_contextids() as $contextid) { if ($request->get('type') == static::DATAREQUEST_TYPE_DELETE) { $context = \context::instance_by_id($contextid); - if (($purpose = static::get_effective_context_purpose($context)) && !empty($purpose->get('protected'))) { + $purpose = static::get_effective_context_purpose($context); + + // Data can only be deleted from it if the context is either expired, or unprotected. + if (!expired_contexts_manager::is_context_expired_or_unprotected_for_user($context, $user)) { continue; } } + $context = new contextlist_context(); $context->set('contextid', $contextid) ->set('contextlistid', $contextlistid) @@ -1099,6 +1104,15 @@ class api { $contexts = []; } + if ($request->get('type') == static::DATAREQUEST_TYPE_DELETE) { + $context = \context::instance_by_id($record->contextid); + $purpose = static::get_effective_context_purpose($context); + // Data can only be deleted from it if the context is either expired, or unprotected. + if (!expired_contexts_manager::is_context_expired_or_unprotected_for_user($context, $foruser)) { + continue; + } + } + $contexts[] = $record->contextid; $lastcomponent = $record->component; } diff --git a/admin/tool/dataprivacy/classes/expired_contexts_manager.php b/admin/tool/dataprivacy/classes/expired_contexts_manager.php index 3f27b807d9a..5be1f5fb75d 100644 --- a/admin/tool/dataprivacy/classes/expired_contexts_manager.php +++ b/admin/tool/dataprivacy/classes/expired_contexts_manager.php @@ -311,44 +311,7 @@ class expired_contexts_manager { })); if (!$shouldskip && $context instanceof \context_user) { - // The context instanceid is the user's ID. - if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) { - // This is an admin, or the guest and cannot be deleted. - $shouldskip = true; - } - - if (!$shouldskip) { - $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']); - $requireenddate = self::require_all_end_dates_for_user_deletion(); - - foreach ($courses as $course) { - if (empty($course->enddate)) { - // This course has no end date. - if ($requireenddate) { - // Course end dates are required, and this course has no end date. - $shouldskip = true; - break; - } - - // Course end dates are not required. The subsequent checks are pointless at this time so just - // skip them. - continue; - } - - if ($course->enddate >= time()) { - // This course is still in the future. - $shouldskip = true; - break; - } - - // This course has an end date which is in the past. - if (!self::is_course_expired($course)) { - // This course has not expired yet. - $shouldskip = true; - break; - } - } - } + $shouldskip = !self::are_user_context_dependencies_expired($context); } if ($shouldskip) { @@ -586,8 +549,9 @@ class expired_contexts_manager { $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate); } - return new expiry_info($defaultexpired, [], []); + return new expiry_info($defaultexpired, $purpose->get('protected'), [], [], []); } else { + $protectedroles = []; foreach ($overrides as $override) { if (static::has_expired($override->get('retentionperiod'), $comparisondate)) { // This role has expired. @@ -595,6 +559,10 @@ class expired_contexts_manager { } else { // This role has not yet expired. $unexpiredroles[] = $override->get('roleid'); + + if ($override->get('protected')) { + $protectedroles[$override->get('roleid')] = true; + } } } @@ -607,7 +575,7 @@ class expired_contexts_manager { $expiredroles = []; } - return new expiry_info($defaultexpired, $expiredroles, $unexpiredroles); + return new expiry_info($defaultexpired, $purpose->get('protected'), $expiredroles, $unexpiredroles, $protectedroles); } } @@ -728,8 +696,8 @@ class expired_contexts_manager { // Find the list of expired role users. $expiredroleuserassignments = get_role_users($expiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id'); $expiredroleusers = array_map(function($assignment) { - return $assignment->userid; - }, $expiredroleuserassignments); + return $assignment->userid; + }, $expiredroleuserassignments); } $expiredroleusers = array_unique($expiredroleusers); @@ -739,8 +707,8 @@ class expired_contexts_manager { // Find the list of unexpired role users. $unexpiredroleuserassignments = get_role_users($unexpiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id'); $unexpiredroleusers = array_map(function($assignment) { - return $assignment->userid; - }, $unexpiredroleuserassignments); + return $assignment->userid; + }, $unexpiredroleuserassignments); } $unexpiredroleusers = array_unique($unexpiredroleusers); @@ -749,12 +717,6 @@ class expired_contexts_manager { $tofilter = array_filter($tofilter, function($userroles) use ($expiredroles) { // Each iteration contains the list of role assignment for a specific user. // All roles that the user holds must match those in the list of expired roles. - if (count($userroles) === 1) { - // Shortcut - only one role held which must be one of the expired roles. - // TODO I think this is wrong. - return false; - } - foreach ($userroles as $ra) { if (false === array_search($ra->roleid, $expiredroles)) { // This role was not found in the list of assignments. @@ -773,6 +735,27 @@ class expired_contexts_manager { ]; } + /** + * Determine whether the supplied context has expired. + * + * @param \context $context + * @return bool + */ + public static function is_context_expired(\context $context) : bool { + $parents = $context->get_parent_contexts(true); + foreach ($parents as $parent) { + if ($parent instanceof \context_course) { + return self::is_course_context_expired($context); + } + + if ($parent instanceof \context_user) { + return self::are_user_context_dependencies_expired($context); + } + } + + return false; + } + /** * Check whether the course has expired. * @@ -781,11 +764,149 @@ class expired_contexts_manager { */ protected static function is_course_expired(\stdClass $course) : bool { $context = \context_course::instance($course->id); + + return self::is_course_context_expired($context); + } + + /** + * Determine whether the supplied course context has expired. + * + * @param \context_course $context + * @return bool + */ + protected static function is_course_context_expired(\context_course $context) : bool { $expiryrecords = self::get_nested_expiry_info_for_courses($context->path); return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired(); } + /** + * Determine whether the supplied user context's dependencies have expired. + * + * This checks whether courses have expired, and some other check, but does not check whether the user themself has expired. + * + * Although this seems unusual at first, each location calling this actually checks whether the user is elgible for + * deletion, irrespective if they have actually expired. + * + * For example, a request to delete the user only cares about course dependencies and the user's lack of expiry + * should not block their own request to be deleted; whilst the expiry eligibility check has already tested for the + * user being expired. + * + * @param \context_user $context + * @return bool + */ + protected static function are_user_context_dependencies_expired(\context_user $context) : bool { + // The context instanceid is the user's ID. + if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) { + // This is an admin, or the guest and cannot expire. + return false; + } + + $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']); + $requireenddate = self::require_all_end_dates_for_user_deletion(); + + $expired = true; + + foreach ($courses as $course) { + if (empty($course->enddate)) { + // This course has no end date. + if ($requireenddate) { + // Course end dates are required, and this course has no end date. + $expired = false; + break; + } + + // Course end dates are not required. The subsequent checks are pointless at this time so just + // skip them. + continue; + } + + if ($course->enddate >= time()) { + // This course is still in the future. + $expired = false; + break; + } + + // This course has an end date which is in the past. + if (!self::is_course_expired($course)) { + // This course has not expired yet. + $expired = false; + break; + } + } + + return $expired; + } + + /** + * Determine whether the supplied context has expired or unprotected for the specified user. + * + * @param \context $context + * @param \stdClass $user + * @return bool + */ + public static function is_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) : bool { + $parents = $context->get_parent_contexts(true); + foreach ($parents as $parent) { + if ($parent instanceof \context_course) { + return self::is_course_context_expired_or_unprotected_for_user($parent, $user); + } + + if ($parent instanceof \context_user) { + return self::are_user_context_dependencies_expired($context); + } + } + + return false; + } + + /** + * Determine whether the supplied course context has expired, or is unprotected. + * + * @param \context_course $context + * @param \stdClass $user + * @return bool + */ + protected static function is_course_context_expired_or_unprotected_for_user(\context_course $context, \stdClass $user) { + $expiryrecords = self::get_nested_expiry_info_for_courses($context->path); + + $info = $expiryrecords[$context->path]->info; + if ($info->is_fully_expired()) { + // This context is fully expired. + return true; + } + + // Now perform user checks. + $userroles = array_map(function($assignment) { + return $assignment->roleid; + }, get_user_roles($context, $user->id)); + + $unexpiredprotectedroles = $info->get_unexpired_protected_roles(); + if (!empty(array_intersect($unexpiredprotectedroles, $userroles))) { + // The user holds an unexpired and protected role. + return false; + } + + $unprotectedoverriddenroles = $info->get_unprotected_overridden_roles(); + $matchingroles = array_intersect($unprotectedoverriddenroles, $userroles); + if (!empty($matchingroles)) { + // This user has at least one overridden role which is not a protected. + // However, All such roles must match. + // If the user has multiple roles then all must be expired, otherwise we should fall back to the default behaviour. + if (empty(array_diff($userroles, $unprotectedoverriddenroles))) { + // All roles that this user holds are a combination of expired, or unprotected. + return true; + } + } + + if ($info->is_default_expired()) { + // If the user has no unexpired roles, and the context is expired by default then this must be expired. + return true; + } + + return !$info->is_default_protected(); + } + /** * Create a new instance of the privacy manager. * diff --git a/admin/tool/dataprivacy/classes/expiry_info.php b/admin/tool/dataprivacy/classes/expiry_info.php index 04d4858241d..5e409af451f 100644 --- a/admin/tool/dataprivacy/classes/expiry_info.php +++ b/admin/tool/dataprivacy/classes/expiry_info.php @@ -41,23 +41,33 @@ class expiry_info { /** @var bool Whether the default expiry value of this purpose has been reached */ protected $defaultexpiryreached = false; + /** @var bool Whether the default purpose is protected */ + protected $defaultprotected = false; + /** @var int[] List of expires roles */ protected $expired = []; /** @var int[] List of unexpires roles */ protected $unexpired = []; + /** @var int[] List of unexpired roles which are also protected */ + protected $protectedroles = []; + /** * Constructor for the expiry_info class. * * @param bool $default Whether the default expiry period for this context has been reached. + * @param bool $defaultprotected Whether the default expiry is protected. * @param int[] $expired A list of roles in this context which have explicitly expired. * @param int[] $unexpired A list of roles in this context which have not yet expired. + * @param int[] $protectedroles A list of unexpired roles in this context which are protected. */ - public function __construct(bool $default, array $expired, array $unexpired) { + public function __construct(bool $default, bool $defaultprotected, array $expired, array $unexpired, array $protectedroles) { $this->defaultexpiryreached = $default; + $this->defaultprotected = $defaultprotected; $this->expired = $expired; $this->unexpired = $unexpired; + $this->protectedroles = $protectedroles; } /** @@ -124,6 +134,15 @@ class expiry_info { return $this->defaultexpiryreached; } + /** + * Whether the default purpose is protected. + * + * @return bool + */ + public function is_default_protected() : bool { + return $this->defaultprotected; + } + /** * Get the list of unexpired role IDs. * @@ -133,6 +152,25 @@ class expiry_info { return $this->unexpired; } + /** + * Get the list of unexpired protected roles. + * + * @return int[] + */ + public function get_unexpired_protected_roles() : array { + return array_keys(array_filter($this->protectedroles)); + } + + /** + * Get a list of all overridden roles which are unprotected. + * @return int[] + */ + public function get_unprotected_overridden_roles() : array { + $allroles = array_merge($this->expired, $this->unexpired); + + return array_diff($allroles, $this->protectedroles); + } + /** * Merge this expiry_info object with another belonging to a child context in order to set the 'safest' heritage. * diff --git a/admin/tool/dataprivacy/tests/api_test.php b/admin/tool/dataprivacy/tests/api_test.php index 3ae2f1f7d23..a290b271aad 100644 --- a/admin/tool/dataprivacy/tests/api_test.php +++ b/admin/tool/dataprivacy/tests/api_test.php @@ -24,11 +24,14 @@ use core\invalid_persistent_exception; use core\task\manager; +use tool_dataprivacy\contextlist_context; use tool_dataprivacy\context_instance; use tool_dataprivacy\api; use tool_dataprivacy\data_registry; use tool_dataprivacy\expired_context; use tool_dataprivacy\data_request; +use tool_dataprivacy\purpose; +use tool_dataprivacy\category; use tool_dataprivacy\local\helper; use tool_dataprivacy\task\initiate_data_request_task; use tool_dataprivacy\task\process_data_request_task; @@ -1376,6 +1379,228 @@ class tool_dataprivacy_api_testcase extends advanced_testcase { $this->assertEquals($data->contexts->used, $contextids, '', 0.0, 10, true); } + /** + * Test that delete requests do not filter out protected purpose contexts if they are already expired. + */ + public function test_add_request_contexts_with_status_delete_course_expired_protected() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $purposes->course->set('protected', 1)->save(); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - YEARSECS]); + $coursecontext = \context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $collection = new \core_privacy\local\request\contextlist_collection($user->id); + $contextlist = new \core_privacy\local\request\contextlist(); + $contextlist->set_component('tool_dataprivacy'); + $contextlist->add_from_sql('SELECT id FROM {context} WHERE id IN(:ctx1)', ['ctx1' => $coursecontext->id]); + $collection->add_contextlist($contextlist); + + $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE); + + $purposes->course->set('protected', 1)->save(); + api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED); + + $requests = contextlist_context::get_records(); + $this->assertCount(1, $requests); + } + + /** + * Test that delete requests does filter out protected purpose contexts which are not expired. + */ + public function test_add_request_contexts_with_status_delete_course_unexpired_protected() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y'); + $purposes->course->set('protected', 1)->save(); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]); + $coursecontext = \context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $collection = new \core_privacy\local\request\contextlist_collection($user->id); + $contextlist = new \core_privacy\local\request\contextlist(); + $contextlist->set_component('tool_dataprivacy'); + $contextlist->add_from_sql('SELECT id FROM {context} WHERE id IN(:ctx1)', ['ctx1' => $coursecontext->id]); + $collection->add_contextlist($contextlist); + + $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE); + + $purposes->course->set('protected', 1)->save(); + api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED); + + $requests = contextlist_context::get_records(); + $this->assertCount(0, $requests); + } + + /** + * Test that delete requests do not filter out unexpired contexts if they are not protected. + */ + public function test_add_request_contexts_with_status_delete_course_unexpired_unprotected() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y'); + $purposes->course->set('protected', 1)->save(); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]); + $coursecontext = \context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $collection = new \core_privacy\local\request\contextlist_collection($user->id); + $contextlist = new \core_privacy\local\request\contextlist(); + $contextlist->set_component('tool_dataprivacy'); + $contextlist->add_from_sql('SELECT id FROM {context} WHERE id IN(:ctx1)', ['ctx1' => $coursecontext->id]); + $collection->add_contextlist($contextlist); + + $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE); + + $purposes->course->set('protected', 0)->save(); + api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED); + + $requests = contextlist_context::get_records(); + $this->assertCount(1, $requests); + } + + /** + * Test that delete requests do not filter out protected purpose contexts if they are already expired. + */ + public function test_get_approved_contextlist_collection_for_request_delete_course_expired_protected() { + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $purposes->course->set('protected', 1)->save(); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - YEARSECS]); + $coursecontext = \context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + // Create the request, with its contextlist and context. + $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE); + $contextlist = new \tool_dataprivacy\contextlist(0, (object) ['component' => 'tool_dataprivacy']); + $contextlist->save(); + + $clcontext = new \tool_dataprivacy\contextlist_context(0, (object) [ + 'contextid' => $coursecontext->id, + 'status' => contextlist_context::STATUS_APPROVED, + 'contextlistid' => $contextlist->get('id'), + ]); + $clcontext->save(); + + $rcl = new \tool_dataprivacy\request_contextlist(0, (object) [ + 'requestid' => $request->get('id'), + 'contextlistid' => $contextlist->get('id'), + ]); + $rcl->save(); + + $purposes->course->set('protected', 1)->save(); + $collection = api::get_approved_contextlist_collection_for_request($request); + + $this->assertCount(1, $collection); + + $list = $collection->get_contextlist_for_component('tool_dataprivacy'); + $this->assertCount(1, $list); + } + + /** + * Test that delete requests does filter out protected purpose contexts which are not expired. + */ + public function test_get_approved_contextlist_collection_for_request_delete_course_unexpired_protected() { + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y'); + $purposes->course->set('protected', 1)->save(); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]); + $coursecontext = \context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + // Create the request, with its contextlist and context. + $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE); + $contextlist = new \tool_dataprivacy\contextlist(0, (object) ['component' => 'tool_dataprivacy']); + $contextlist->save(); + + $clcontext = new \tool_dataprivacy\contextlist_context(0, (object) [ + 'contextid' => $coursecontext->id, + 'status' => contextlist_context::STATUS_APPROVED, + 'contextlistid' => $contextlist->get('id'), + ]); + $clcontext->save(); + + $rcl = new \tool_dataprivacy\request_contextlist(0, (object) [ + 'requestid' => $request->get('id'), + 'contextlistid' => $contextlist->get('id'), + ]); + $rcl->save(); + + $purposes->course->set('protected', 1)->save(); + $collection = api::get_approved_contextlist_collection_for_request($request); + + $this->assertCount(0, $collection); + + $list = $collection->get_contextlist_for_component('tool_dataprivacy'); + $this->assertEmpty($list); + } + + /** + * Test that delete requests do not filter out unexpired contexts if they are not protected. + */ + public function test_get_approved_contextlist_collection_for_request_delete_course_unexpired_unprotected() { + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y'); + $purposes->course->set('protected', 1)->save(); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]); + $coursecontext = \context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + // Create the request, with its contextlist and context. + $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE); + $contextlist = new \tool_dataprivacy\contextlist(0, (object) ['component' => 'tool_dataprivacy']); + $contextlist->save(); + + $clcontext = new \tool_dataprivacy\contextlist_context(0, (object) [ + 'contextid' => $coursecontext->id, + 'status' => contextlist_context::STATUS_APPROVED, + 'contextlistid' => $contextlist->get('id'), + ]); + $clcontext->save(); + + $rcl = new \tool_dataprivacy\request_contextlist(0, (object) [ + 'requestid' => $request->get('id'), + 'contextlistid' => $contextlist->get('id'), + ]); + $rcl->save(); + + $purposes->course->set('protected', 0)->save(); + $collection = api::get_approved_contextlist_collection_for_request($request); + + $this->assertCount(1, $collection); + + $list = $collection->get_contextlist_for_component('tool_dataprivacy'); + $this->assertCount(1, $list); + } + /** * Data provider for \tool_dataprivacy_api_testcase::test_set_context_defaults */ @@ -1635,4 +1860,66 @@ class tool_dataprivacy_api_testcase extends advanced_testcase { 'list' => $approvedcollection->get_contextlist_for_component('tool_dataprivacy'), ]; } + + /** + * Setup the basics with the specified retention period. + * + * @param string $system Retention policy for the system. + * @param string $user Retention policy for users. + * @param string $course Retention policy for courses. + * @param string $activity Retention policy for activities. + */ + protected function setup_basics(string $system, string $user, string $course = null, string $activity = null) : \stdClass { + $this->resetAfterTest(); + + $purposes = (object) [ + 'system' => $this->create_and_set_purpose_for_contextlevel($system, CONTEXT_SYSTEM), + 'user' => $this->create_and_set_purpose_for_contextlevel($user, CONTEXT_USER), + ]; + + if (null !== $course) { + $purposes->course = $this->create_and_set_purpose_for_contextlevel($course, CONTEXT_COURSE); + } + + if (null !== $activity) { + $purposes->activity = $this->create_and_set_purpose_for_contextlevel($activity, CONTEXT_MODULE); + } + + return $purposes; + } + + /** + * Create a retention period and set it for the specified context level. + * + * @param string $retention + * @param int $contextlevel + * @return purpose + */ + protected function create_and_set_purpose_for_contextlevel(string $retention, int $contextlevel) : purpose { + $purpose = new purpose(0, (object) [ + 'name' => 'Test purpose ' . rand(1, 1000), + 'retentionperiod' => $retention, + 'lawfulbases' => 'gdpr_art_6_1_a', + ]); + $purpose->create(); + + $cat = new category(0, (object) ['name' => 'Test category']); + $cat->create(); + + if ($contextlevel <= CONTEXT_USER) { + $record = (object) [ + 'purposeid' => $purpose->get('id'), + 'categoryid' => $cat->get('id'), + 'contextlevel' => $contextlevel, + ]; + api::set_contextlevel($record); + } else { + list($purposevar, ) = data_registry::var_names_from_context( + \context_helper::get_class_for_level(CONTEXT_COURSE) + ); + set_config($purposevar, $purpose->get('id'), 'tool_dataprivacy'); + } + + return $purpose; + } } diff --git a/admin/tool/dataprivacy/tests/expired_contexts_test.php b/admin/tool/dataprivacy/tests/expired_contexts_test.php index f7e25864e60..6058ac5502b 100644 --- a/admin/tool/dataprivacy/tests/expired_contexts_test.php +++ b/admin/tool/dataprivacy/tests/expired_contexts_test.php @@ -29,6 +29,7 @@ use tool_dataprivacy\purpose; use tool_dataprivacy\purpose_override; use tool_dataprivacy\category; use tool_dataprivacy\contextlevel; +use tool_dataprivacy\expired_contexts_manager; defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -50,15 +51,20 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { * @param string $course Retention policy for courses. * @param string $activity Retention policy for activities. */ - protected function setup_basics(string $system, string $user, string $course, string $activity = null) : array { + protected function setup_basics(string $system, string $user, string $course = null, string $activity = null) : \stdClass { $this->resetAfterTest(); - $purposes = []; - $purposes[] = $this->create_and_set_purpose_for_contextlevel($system, CONTEXT_SYSTEM); - $purposes[] = $this->create_and_set_purpose_for_contextlevel($user, CONTEXT_USER); - $purposes[] = $this->create_and_set_purpose_for_contextlevel($course, CONTEXT_COURSE); + $purposes = (object) [ + 'system' => $this->create_and_set_purpose_for_contextlevel($system, CONTEXT_SYSTEM), + 'user' => $this->create_and_set_purpose_for_contextlevel($user, CONTEXT_USER), + ]; + + if (null !== $course) { + $purposes->course = $this->create_and_set_purpose_for_contextlevel($course, CONTEXT_COURSE); + } + if (null !== $activity) { - $purposes[] = $this->create_and_set_purpose_for_contextlevel($activity, CONTEXT_MODULE); + $purposes->activity = $this->create_and_set_purpose_for_contextlevel($activity, CONTEXT_MODULE); } return $purposes; @@ -286,7 +292,6 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); - $userpurpose = $purposes[1]; $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); $usercontext = \context_user::instance($user->id); @@ -295,7 +300,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $role = $DB->get_record('role', ['shortname' => 'manager']); $override = new purpose_override(0, (object) [ - 'purposeid' => $userpurpose->get('id'), + 'purposeid' => $purposes->user->get('id'), 'roleid' => $role->id, 'retentionperiod' => 'P5Y', ]); @@ -445,7 +450,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { public function test_flag_user_retention_changed() { $this->resetAfterTest(); - list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); $usercontext = \context_user::instance($user->id); @@ -466,8 +471,8 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->assertNotFalse($expiredcontext); // Increase the retention period to 5 years. - $userpurpose->set('retentionperiod', 'P5Y'); - $userpurpose->save(); + $purposes->user->set('retentionperiod', 'P5Y'); + $purposes->user->save(); // Re-run the expiry job - the previously flagged user will be removed because the retention period has been increased. list($flaggedcourses, $flaggedusers) = $manager->flag_expired_contexts(); @@ -485,7 +490,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { public function test_flag_user_historic_block_unapproved() { $this->resetAfterTest(); - list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); $usercontext = \context_user::instance($user->id); @@ -522,8 +527,8 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { public function test_flag_user_historic_unexpired_child() { $this->resetAfterTest(); - list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); - $blockpurpose = $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK); + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK); $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); $usercontext = \context_user::instance($user->id); @@ -680,12 +685,11 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); - $coursepurpose = $purposes[2]; $role = $DB->get_record('role', ['shortname' => 'editingteacher']); $override = new purpose_override(0, (object) [ - 'purposeid' => $coursepurpose->get('id'), + 'purposeid' => $purposes->course->get('id'), 'roleid' => $role->id, 'retentionperiod' => 'P5Y', ]); @@ -720,13 +724,12 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); - $coursepurpose = $purposes[2]; $role = $DB->get_record('role', ['shortname' => 'student']); // The role has a much shorter retention, but both should match. $override = new purpose_override(0, (object) [ - 'purposeid' => $coursepurpose->get('id'), + 'purposeid' => $purposes->course->get('id'), 'roleid' => $role->id, 'retentionperiod' => 'PT1M', ]); @@ -760,12 +763,11 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('P1Y', 'P1Y', 'P1Y'); - $coursepurpose = $purposes[2]; $role = $DB->get_record('role', ['shortname' => 'editingteacher']); (new purpose_override(0, (object) [ - 'purposeid' => $coursepurpose->get('id'), + 'purposeid' => $purposes->course->get('id'), 'roleid' => $role->id, 'retentionperiod' => 'PT1S', ]))->save(); @@ -830,7 +832,6 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); - $userpurpose = $purposes[1]; $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); $usercontext = \context_user::instance($user->id); @@ -839,7 +840,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $role = $DB->get_record('role', ['shortname' => 'manager']); $override = new purpose_override(0, (object) [ - 'purposeid' => $userpurpose->get('id'), + 'purposeid' => $purposes->user->get('id'), 'roleid' => $role->id, 'retentionperiod' => 'P5Y', ]); @@ -889,12 +890,11 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); - $coursepurpose = $purposes[2]; $role = $DB->get_record('role', ['shortname' => 'editingteacher']); $override = new purpose_override(0, (object) [ - 'purposeid' => $coursepurpose->get('id'), + 'purposeid' => $purposes->course->get('id'), 'roleid' => $role->id, 'retentionperiod' => 'P5Y', ]); @@ -978,12 +978,11 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('PT1H', 'PT1H', 'P5Y'); - $coursepurpose = $purposes[2]; $role = $DB->get_record('role', ['shortname' => 'student']); $override = new purpose_override(0, (object) [ - 'purposeid' => $coursepurpose->get('id'), + 'purposeid' => $purposes->course->get('id'), 'roleid' => $role->id, 'retentionperiod' => 'PT1M', ]); @@ -1067,12 +1066,11 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('PT1H', 'PT1H', 'P5Y'); - $coursepurpose = $purposes[2]; $role = $DB->get_record('role', ['shortname' => 'student']); $override = new purpose_override(0, (object) [ - 'purposeid' => $coursepurpose->get('id'), + 'purposeid' => $purposes->course->get('id'), 'roleid' => $role->id, 'retentionperiod' => 'PT1M', ]); @@ -1157,11 +1155,10 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $this->resetAfterTest(); $purposes = $this->setup_basics('PT1H', 'PT1H', 'P5Y'); - $coursepurpose = $purposes[2]; $studentrole = $DB->get_record('role', ['shortname' => 'student']); $override = new purpose_override(0, (object) [ - 'purposeid' => $coursepurpose->get('id'), + 'purposeid' => $purposes->course->get('id'), 'roleid' => $studentrole->id, 'retentionperiod' => 'PT1M', ]); @@ -1169,7 +1166,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $override = new purpose_override(0, (object) [ - 'purposeid' => $coursepurpose->get('id'), + 'purposeid' => $purposes->course->get('id'), 'roleid' => $teacherrole->id, 'retentionperiod' => 'PT1M', ]); @@ -1578,7 +1575,7 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { public function test_process_user_historic_block_unapproved() { $this->resetAfterTest(); - list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); $usercontext = \context_user::instance($user->id); @@ -1637,8 +1634,8 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { public function test_process_user_historic_unexpired_child() { $this->resetAfterTest(); - list($systempurpose, $userpurpose, $coursepurpose) = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); - $blockpurpose = $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK); + $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + $this->create_and_set_purpose_for_contextlevel('P5Y', CONTEXT_BLOCK); $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - DAYSECS]); $usercontext = \context_user::instance($user->id); @@ -1722,9 +1719,8 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { $manager->set_progress(new \null_progress_trace()); $manager->method('get_privacy_manager')->willReturn($mockprivacymanager); - $coursepurpose = $purposes[2]; - $coursepurpose->set('retentionperiod', 'P5Y'); - $coursepurpose->save(); + $purposes->course->set('retentionperiod', 'P5Y'); + $purposes->course->save(); list($processedcourses, $processedusers) = $manager->process_approved_deletions(); @@ -2185,4 +2181,387 @@ class tool_dataprivacy_expired_contexts_testcase extends advanced_testcase { return $block; } + + /** + * Test the is_context_expired functions when supplied with the system context. + */ + public function test_is_context_expired_system() { + global $DB; + + $this->resetAfterTest(); + $this->setup_basics('PT1H', 'PT1H', 'P1D'); + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + + $this->assertFalse(expired_contexts_manager::is_context_expired(\context_system::instance())); + $this->assertFalse( + expired_contexts_manager::is_context_expired_or_unprotected_for_user(\context_system::instance(), $user)); + } + + /** + * Test the is_context_expired functions when supplied with an expired course. + */ + public function test_is_context_expired_course_expired() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]); + $coursecontext = \context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext)); + + $purposes->course->set('protected', 1)->save(); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + $purposes->course->set('protected', 0)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + } + + /** + * Test the is_context_expired functions when supplied with an unexpired course. + */ + public function test_is_context_expired_course_unexpired() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1D'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]); + $coursecontext = \context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $this->assertTrue(expired_contexts_manager::is_context_expired($coursecontext)); + + $purposes->course->set('protected', 1)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + $purposes->course->set('protected', 0)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + } + + /** + * Test the is_context_expired functions when supplied with an expired course which has role overrides. + */ + public function test_is_context_expired_course_expired_override() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]); + $coursecontext = \context_course::instance($course->id); + $systemcontext = \context_system::instance(); + + $role = $DB->get_record('role', ['shortname' => 'manager']); + $override = new purpose_override(0, (object) [ + 'purposeid' => $purposes->course->get('id'), + 'roleid' => $role->id, + 'retentionperiod' => 'P5Y', + ]); + $override->save(); + role_assign($role->id, $user->id, $systemcontext->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext)); + + $purposes->course->set('protected', 1)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + $purposes->course->set('protected', 0)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + } + + /** + * Test the is_context_expired functions when supplied with an expired course which has role overrides. + */ + public function test_is_context_expired_course_expired_override_parent() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]); + $coursecontext = \context_course::instance($course->id); + $systemcontext = \context_system::instance(); + + $role = $DB->get_record('role', ['shortname' => 'manager']); + $override = new purpose_override(0, (object) [ + 'purposeid' => $purposes->system->get('id'), + 'roleid' => $role->id, + 'retentionperiod' => 'P5Y', + ]); + $override->save(); + role_assign($role->id, $user->id, $systemcontext->id); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext)); + + // The user override applies to this user. THIs means that the default expiry has no effect. + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + $purposes->system->set('protected', 1)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + $purposes->system->set('protected', 0)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + $override->set('protected', 1)->save(); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + $purposes->system->set('protected', 1)->save(); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + $purposes->system->set('protected', 0)->save(); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $user)); + + } + + /** + * Test the is_context_expired functions when supplied with an expired course which has role overrides but the user + * does not hold the role. + */ + public function test_is_context_expired_course_expired_override_parent_no_role() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1H', 'PT1H'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]); + $coursecontext = \context_course::instance($course->id); + $systemcontext = \context_system::instance(); + + $role = $DB->get_record('role', ['shortname' => 'manager']); + $override = new purpose_override(0, (object) [ + 'purposeid' => $purposes->system->get('id'), + 'roleid' => $role->id, + 'retentionperiod' => 'P5Y', + ]); + $override->save(); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + // This context is not _fully _ expired. + $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext)); + } + + /** + * Test the is_context_expired functions when supplied with an unexpired course which has role overrides. + */ + public function test_is_context_expired_course_expired_override_inverse() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('P1Y', 'P1Y'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]); + $coursecontext = \context_course::instance($course->id); + $systemcontext = \context_system::instance(); + + $role = $DB->get_record('role', ['shortname' => 'student']); + $override = new purpose_override(0, (object) [ + 'purposeid' => $purposes->system->get('id'), + 'roleid' => $role->id, + 'retentionperiod' => 'PT1S', + ]); + $override->save(); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + + // This context is not _fully _ expired. + $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext)); + } + + /** + * Test the is_context_expired functions when supplied with an unexpired course which has role overrides. + */ + public function test_is_context_expired_course_expired_override_inverse_parent() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('P1Y', 'P1Y'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]); + $coursecontext = \context_course::instance($course->id); + $systemcontext = \context_system::instance(); + + $role = $DB->get_record('role', ['shortname' => 'manager']); + $override = new purpose_override(0, (object) [ + 'purposeid' => $purposes->system->get('id'), + 'roleid' => $role->id, + 'retentionperiod' => 'PT1S', + ]); + $override->save(); + + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + role_assign($role->id, $user->id, $systemcontext->id); + + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + role_unassign($studentrole->id, $user->id, $coursecontext->id); + + // This context is not _fully _ expired. + $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext)); + } + + /** + * Test the is_context_expired functions when supplied with an unexpired course which has role overrides. + */ + public function test_is_context_expired_course_expired_override_inverse_parent_not_assigned() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('P1Y', 'P1Y'); + + $user = $this->getDataGenerator()->create_user(['lastaccess' => time() - YEARSECS]); + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - WEEKSECS]); + $coursecontext = \context_course::instance($course->id); + $systemcontext = \context_system::instance(); + + $role = $DB->get_record('role', ['shortname' => 'manager']); + $override = new purpose_override(0, (object) [ + 'purposeid' => $purposes->system->get('id'), + 'roleid' => $role->id, + 'retentionperiod' => 'PT1S', + ]); + $override->save(); + + // Enrol the user in the course without any role. + $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student'); + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + role_unassign($studentrole->id, $user->id, $coursecontext->id); + + // This context is not _fully _ expired. + $this->assertFalse(expired_contexts_manager::is_context_expired($coursecontext)); + } + + /** + * Ensure that context expired checks for a specific user taken into account roles. + */ + public function test_is_context_expired_or_unprotected_for_user_role_mixtures_protected() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('PT1S', 'PT1S', 'PT1S'); + + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - DAYSECS]); + $coursecontext = \context_course::instance($course->id); + $systemcontext = \context_system::instance(); + + $roles = $DB->get_records_menu('role', [], 'id', 'shortname, id'); + $override = new purpose_override(0, (object) [ + 'purposeid' => $purposes->course->get('id'), + 'roleid' => $roles['manager'], + 'retentionperiod' => 'P1W', + 'protected' => 1, + ]); + $override->save(); + + $s = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($s->id, $course->id, 'student'); + + $t = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($t->id, $course->id, 'teacher'); + + $sm = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($sm->id, $course->id, 'student'); + role_assign($roles['manager'], $sm->id, $coursecontext->id); + + $m = $this->getDataGenerator()->create_user(); + role_assign($roles['manager'], $m->id, $coursecontext->id); + + $tm = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($t->id, $course->id, 'teacher'); + role_assign($roles['manager'], $tm->id, $coursecontext->id); + + // The context should only be expired for users who are not a manager. + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $s)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $t)); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $sm)); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $tm)); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $m)); + + $override->set('protected', 0)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $s)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $t)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $sm)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $tm)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $m)); + } + + /** + * Ensure that context expired checks for a specific user taken into account roles when retention is inversed. + */ + public function test_is_context_expired_or_unprotected_for_user_role_mixtures_protected_inverse() { + global $DB; + + $this->resetAfterTest(); + + $purposes = $this->setup_basics('P5Y', 'P5Y', 'P5Y'); + + $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - DAYSECS]); + $coursecontext = \context_course::instance($course->id); + $systemcontext = \context_system::instance(); + + $roles = $DB->get_records_menu('role', [], 'id', 'shortname, id'); + $override = new purpose_override(0, (object) [ + 'purposeid' => $purposes->course->get('id'), + 'roleid' => $roles['student'], + 'retentionperiod' => 'PT1S', + ]); + $override->save(); + + $s = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($s->id, $course->id, 'student'); + + $t = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($t->id, $course->id, 'teacher'); + + $sm = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($sm->id, $course->id, 'student'); + role_assign($roles['manager'], $sm->id, $coursecontext->id); + + $m = $this->getDataGenerator()->create_user(); + role_assign($roles['manager'], $m->id, $coursecontext->id); + + $tm = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($t->id, $course->id, 'teacher'); + role_assign($roles['manager'], $tm->id, $coursecontext->id); + + // The context should only be expired for users who are only a student. + $purposes->course->set('protected', 1)->save(); + $override->set('protected', 1)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $s)); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $t)); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $sm)); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $tm)); + $this->assertFalse(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $m)); + + $purposes->course->set('protected', 0)->save(); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $s)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $t)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $sm)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $tm)); + $this->assertTrue(expired_contexts_manager::is_context_expired_or_unprotected_for_user($coursecontext, $m)); + } }