MDL-63496 tool_dataprivacy: Respect expiry with protected flag
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 2 Oct 2018 06:56:29 +0000 (14:56 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 17 Oct 2018 05:12:18 +0000 (13:12 +0800)
This issue is a part of the MDL-62560 Epic.

admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/expired_contexts_manager.php
admin/tool/dataprivacy/classes/expiry_info.php
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/expired_contexts_test.php

index f626371..8b2142f 100644 (file)
@@ -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;
         }
index 3f27b80..5be1f5f 100644 (file)
@@ -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.
      *
index 04d4858..5e409af 100644 (file)
@@ -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.
      *
index 3ae2f1f..a290b27 100644 (file)
 
 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;
+    }
 }
index f7e2586..6058ac5 100644 (file)
@@ -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));
+    }
 }