diff --git a/public/course/classes/route/controller/restricted_module.php b/public/course/classes/route/controller/restricted_module.php index 4ebeaa9ee39..b4b5d143934 100644 --- a/public/course/classes/route/controller/restricted_module.php +++ b/public/course/classes/route/controller/restricted_module.php @@ -66,6 +66,24 @@ class restricted_module { $format = course_get_format($course); $course->format = $format->get_format(); + // Confirm the module has a view page. Redirect to section page otherwise. + if (!$cmurl = $cminfo->get_url()) { + $cmurl = $format->get_view_url($sectioninfo->section, ['navigation' => true]); + $cmurl->set_anchor('module-' . $cmdata->id); + return $this->redirect($response, $cmurl); + } + + // Confirm the module is actually restricted. Redirect to module page otherwise. + if ($cminfo->get_user_visible()) { + return $this->redirect($response, $cmurl); + } + + // Confirm the module is not stealth and restrictions are visible. + // Redirect to module page to require_login() otherwise. + if (!$cminfo->is_visible_on_course_page()) { + return $this->redirect($response, $cmurl); + } + $url = \core\router\util::get_path_for_callable( [self::class, 'restricted_module_page'], ['cm' => $cmdata->id], @@ -89,9 +107,13 @@ class restricted_module { ); $renderer = $format->get_renderer($PAGE); - $response->getBody()->write($OUTPUT->header()); + if ($header = $OUTPUT->header()) { + $response->getBody()->write($header); + } $response->getBody()->write($renderer->render($cmoutput)); - $response->getBody()->write($OUTPUT->footer()); + if ($footer = $OUTPUT->footer()) { + $response->getBody()->write($footer); + } $eventdata = [ 'objectid' => $cmdata->id, diff --git a/public/course/classes/route/controller/restricted_section.php b/public/course/classes/route/controller/restricted_section.php index 2082792fdf8..d863c1c889b 100644 --- a/public/course/classes/route/controller/restricted_section.php +++ b/public/course/classes/route/controller/restricted_section.php @@ -59,6 +59,13 @@ class restricted_section { require_once($CFG->dirroot . '/course/lib.php'); + // Confirm the section is actually restricted. Redirect to section page otherwise. + $modinfo = get_fast_modinfo($section->course); + $sectioninfo = $modinfo->get_section_info($section->section, MUST_EXIST); + if ($sectioninfo->uservisible) { + $url = course_get_url($section->course, $section->section, ['navigation' => true]); + return $this->redirect($response, $url); + } $course = get_course($section->course); $context = \context_course::instance($course->id); $format = course_get_format($course->id); @@ -81,9 +88,13 @@ class restricted_section { $PAGE->set_heading($sectiontitle); $PAGE->set_secondary_navigation(false); $renderer = $format->get_renderer($PAGE); - $response->getBody()->write($OUTPUT->header()); + if ($header = $OUTPUT->header()) { + $response->getBody()->write($header); + } $response->getBody()->write($renderer->render($sectionoutput)); - $response->getBody()->write($OUTPUT->footer()); + if ($footer = $OUTPUT->footer()) { + $response->getBody()->write($footer); + } // Trigger section viewed event. course_section_view($context, $section->id, true); diff --git a/public/course/tests/route/controller/restricted_module_test.php b/public/course/tests/route/controller/restricted_module_test.php new file mode 100644 index 00000000000..7f6130e2ed9 --- /dev/null +++ b/public/course/tests/route/controller/restricted_module_test.php @@ -0,0 +1,182 @@ +. + +namespace core_course\route\controller; + +use core\router\route_loader_interface; +use core\tests\router\route_testcase; +use core\url; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Restricted module controller tests. + * + * @package core_course + * @copyright 2026 Amaia Anabitarte + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + **/ +#[CoversClass(\core_course\route\controller\restricted_module::class)] +final class restricted_module_test extends route_testcase { + /** + * Test the restricted module in different conditions. + * + * @param string $role + * @param string $modulename + * @param int $timedifference Positive number for not filled requirement, + * and negative for filled requirement. + * @param bool $showrestriction Whether the restriction should be available or not. + * @param bool $visible 'visible' value for hidden modules. + * @param bool $visibleoncourse 'visibleoncoursepage' value for stealth modules. + * @param int $expectedstatus Expected response status code. + * @param string $redirection Expected redirection URL. + */ + #[DataProvider('restricted_module_provider')] + public function test_restricted_module( + string $role = 'student', + string $modulename = 'page', + int $timedifference = 3600, + bool $showrestriction = true, + bool $visible = true, + bool $visibleoncourse = true, + int $expectedstatus = 200, + string $redirection = 'empty', + ): void { + + $this->resetAfterTest(); + + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + + $restriction = json_encode(\core_availability\tree::get_root_json( + [ + \availability_date\condition::get_json( + \availability_date\condition::DIRECTION_FROM, + time() + $timedifference, + ), + ], + '&', + $showrestriction, + )); + $module = $this->getDataGenerator()->create_module($modulename, [ + 'course' => $course->id, + 'visible' => $visible, + 'visibleoncourse' => $visibleoncourse, + 'availability' => $restriction, + ]); + + $user = $generator->create_and_enrol($course, $role); + $this->setUser($user); + + $response = $this->process_request( + 'GET', + 'course/cms/' . $module->cmid . '/restricted', + route_loader_interface::ROUTE_GROUP_PAGE + ); + + $this->assert_valid_response($response, $expectedstatus); + $location = $response->getHeader('Location'); + switch ($redirection) { + case 'empty': + $this->assertEmpty($location, 'There is no redirection.'); + break; + case 'module': + $this->assertNotEmpty($location[0]); + $this->assertEquals( + new url("/mod/{$modulename}/view.php", ['id' => $module->cmid]), + new url($location[0]) + ); + break; + case 'section': + $this->assertNotEmpty($location[0]); + $this->assertStringContainsString('course/section.php', $location[0]); + break; + } + } + + /** + * Data provider for test_restricted_module. + * + * @return \Generator + */ + public static function restricted_module_provider(): \Generator { + yield 'Teacher skipping restrictions' => [ + 'role' => 'teacher', + 'modulename' => 'page', + 'timedifference' => 3600, + 'showrestriction' => true, + 'expectedstatus' => 302, + 'redirection' => 'module', + ]; + yield 'Applied restriction (student)' => [ + 'role' => 'student', + 'modulename' => 'page', + 'timedifference' => 3600, + 'showrestriction' => true, + 'expectedstatus' => 200, + 'redirection' => 'empty', + ]; + yield 'Not applied restriction (student)' => [ + 'role' => 'student', + 'modulename' => 'page', + 'timedifference' => -3600, + 'showrestriction' => true, + 'expectedstatus' => 302, + 'redirection' => 'module', + ]; + yield 'Modules with no url - Text and media (student)' => [ + // Redirect to section page and scroll to the module. + 'role' => 'student', + 'modulename' => 'label', + 'timedifference' => 3600, + 'showrestriction' => true, + 'expectedstatus' => 302, + 'redirection' => 'section', + ]; + yield 'Stealth restricted activities (student)' => [ + // Show restricted page for stealth restricted pages. + 'role' => 'student', + 'modulename' => 'page', + 'timedifference' => 3600, + 'showrestriction' => true, + 'visibleoncourse' => false, + 'expectedstatus' => 200, + 'redirection' => 'empty', + ]; + yield 'Hidden restricted activities (student)' => [ + // Redirect to view page of hidden modules to let + // view.php and require_login() to take care of them. + 'role' => 'student', + 'modulename' => 'page', + 'timedifference' => 3600, + 'showrestriction' => true, + 'visible' => false, + 'visibleoncourse' => false, + 'expectedstatus' => 302, + 'redirection' => 'module', + ]; + yield 'Hidden restrictions (student)' => [ + // Redirect to view page of hidden restriction modules to let + // view.php and require_login() to take care of them. + 'role' => 'student', + 'modulename' => 'page', + 'timedifference' => 3600, + 'showrestriction' => false, + 'expectedstatus' => 302, + 'redirection' => 'module', + ]; + } +} diff --git a/public/course/tests/route/controller/restricted_section_test.php b/public/course/tests/route/controller/restricted_section_test.php new file mode 100644 index 00000000000..4b7f66a03a6 --- /dev/null +++ b/public/course/tests/route/controller/restricted_section_test.php @@ -0,0 +1,118 @@ +. + +namespace core_course\route\controller; + +use core\router\route_loader_interface; +use core\tests\router\route_testcase; +use core\url; +use core_courseformat\formatactions; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Restricted section controller tests. + * + * @package core_course + * @copyright 2026 Amaia Anabitarte + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + **/ +#[CoversClass(\core_course\route\controller\restricted_section::class)] +final class restricted_section_test extends route_testcase { + /** + * Test the restricted section URL redirections for none restricted sections. + * + * The reason why the section is not restricted for the user + * (whether the user has enough permission to see the section page or + * whether the restrictions don't apply to the user) is not important. + * So we are testing only the case where the uservisible is true + * (because the user has enough permisssio) and uservisible is false + * (because the restriction applies to a student with no enough permission) + * + * @param string $role + * @param int $expectedstatus Expected response status code. + * @param bool $redirection Whether redirection should happen or not. + */ + #[DataProvider('restricted_section_provider')] + public function test_restricted_section( + string $role, + int $expectedstatus, + bool $redirection, + ): void { + + $this->resetAfterTest(); + + $generator = $this->getDataGenerator(); + $course = $generator->create_course(['numsections' => 2]); + $modinfo = get_fast_modinfo($course); + + $restriction = json_encode(\core_availability\tree::get_root_json( + [ + \availability_date\condition::get_json( + \availability_date\condition::DIRECTION_FROM, + time() + 3600, + ), + ], + '&', + true, + )); + + // Restrict Section 1. + formatactions::section($course)->update( + $modinfo->get_section_info(1), + ['availability' => $restriction], + ); + + $user = $generator->create_and_enrol($course, $role); + $this->setUser($user); + $restrictedsection = $modinfo->get_section_info(1); + $response = $this->process_request( + 'GET', + 'course/sections/' . $restrictedsection->id . '/restricted', + route_loader_interface::ROUTE_GROUP_PAGE + ); + + $this->assert_valid_response($response, $expectedstatus); + $location = $response->getHeader('Location'); // Just to consume the header if any. + if ($redirection) { + $this->assertNotEmpty($location, 'The redirection header should be present.'); + $this->assertEquals( + new url('/course/section.php', ['id' => $restrictedsection->id]), + new url($location[0]) + ); + } else { + $this->assertEmpty($location, 'There is no redirection.'); + } + } + + /** + * Data provider for test_restricted_section. + * + * @return \Generator + */ + public static function restricted_section_provider(): \Generator { + yield 'Teacher - Permission to see restricted page' => [ + 'role' => 'teacher', + 'expectedstatus' => 302, + 'redirection' => true, + ]; + yield 'Student - Stays in the restricted page' => [ + 'role' => 'student', + 'expectedstatus' => 200, + 'redirection' => false, + ]; + } +}