diff --git a/lib/src/Controllers/Courses.php b/lib/src/Controllers/Courses.php index c4bf9eb26..7d9b1188d 100644 --- a/lib/src/Controllers/Courses.php +++ b/lib/src/Controllers/Courses.php @@ -147,6 +147,49 @@ public static function restoreSiteLoginContext() { return false; } + /** + * A site URL is not the launch course. Drop the top-level course fields. + * + * The LTI row stays, so /courses/{id} for that launch can match it again. + * Google site login keeps its own course. A course-mounted URL is that course. + */ + public static function releaseUnmountedCourse() { + if ( ! defined('COOKIE_SESSION') ) { + return false; + } + if ( self::isCourseMountedRequest() ) { + return false; + } + if ( Settings::isCartridgeUploadRequest() ) { + return false; + } + if ( self::isGoogleLoginSession() ) { + return false; + } + $ltiKey = defined('TSUGI_SESSION_LTI') ? TSUGI_SESSION_LTI : 'lti'; + $ltiContext = 0; + if ( isset($_SESSION[$ltiKey]) && is_array($_SESSION[$ltiKey]) ) { + $ltiContext = (int) ($_SESSION[$ltiKey]['context_id'] ?? 0); + } + $top = (int) ($_SESSION['context_id'] ?? 0); + if ( $ltiContext < 1 && $top < 1 && Manifest::activeId() < 1 + && ! isset($_SESSION['context_title']) ) { + return false; + } + unset( + $_SESSION['context_id'], + $_SESSION['context_key'], + $_SESSION['context_title'], + $_SESSION['membership_id'], + $_SESSION['isinstructor'] + ); + Manifest::rememberInSession(0); + Cache::clearAllSessionCaches(); + Output::clearTopNavSession(); + ReqScope::resetIdentity(); + return true; + } + /** * Google site-login context id, or 0. */ @@ -1177,6 +1220,39 @@ public static function createGateResponse($home) { */ private static function switchFailedResponse($message) { $status = ($message === 'You are not a member of that course.') ? 403 : 400; - return new Response($message, $status); + $copy = self::courseSwitchErrorCopy($message); + $response = new Response(self::renderCourseSwitchError($copy), $status); + $response->headers->set('Content-Type', 'text/html; charset=UTF-8'); + return $response; + } + + /** + * @param string $message + * @return array{title: string, paragraphs: array, home: string} + */ + private static function courseSwitchErrorCopy($message) { + $home = self::appHomeUrl(); + if ( $message === 'You are not a member of that course.' ) { + return array( + 'title' => __('This session cannot open this course'), + 'paragraphs' => array( + __('This session may have been logged in or logged out on another tab, and the new session no longer has access to this course.'), + ), + 'home' => $home, + ); + } + $text = is_string($message) && $message !== '' ? $message : __('Cannot open this course.'); + return array( + 'title' => __('Cannot open this course'), + 'paragraphs' => array($text), + 'home' => $home, + ); + } + + /** + * @param array{title: string, paragraphs: array, home: string} $copy + */ + private static function renderCourseSwitchError(array $copy) { + return self::renderLeavePage($copy); } } diff --git a/lib/src/Controllers/Files.php b/lib/src/Controllers/Files.php index 168dafbd3..e21658970 100644 --- a/lib/src/Controllers/Files.php +++ b/lib/src/Controllers/Files.php @@ -924,7 +924,7 @@ private function ensureFilesLaunch() LTIX::getConnection(); $context_id = ReqScope::currentContextId(); if ( ! $context_id ) { - die('Context required'); + $this->exitCourseRequired(); } $link_id = FileRepository::ensureLink($context_id); diff --git a/lib/src/Controllers/Grades.php b/lib/src/Controllers/Grades.php index a5bdfcf9f..86e4bf09a 100644 --- a/lib/src/Controllers/Grades.php +++ b/lib/src/Controllers/Grades.php @@ -83,14 +83,18 @@ public static function routes(Application $app, $prefix=self::ROUTE) { } /** - * Get context title from session or database - * + * Course title for a grade-book page. Course 0 is the absence of a course. + * * @param int $context_id * @return string */ private function getContextTitle($context_id) { global $CFG, $PDOX; - + + if ( (int) $context_id < 1 ) { + return ''; + } + $context_title = U::get($_SESSION, 'context_title'); if ( ! $context_title ) { $context_info = $PDOX->rowDie( @@ -102,6 +106,17 @@ private function getContextTitle($context_id) { return $context_title; } + /** + * @param string $context_title + * @return string + */ + private function classHeading($context_title) { + if ( ! is_string($context_title) || trim($context_title) === '' ) { + return ''; + } + return '

Class: '.htmlspecialchars($context_title)."

\n"; + } + /** * Get user info for display * @@ -600,7 +615,7 @@ public function index(Request $request) ?>

-

Class:

+ classHeading($context_title) ?> printGradeCategoryStyles(); $outline_url = $this->outlineTabUrl($view_user_id); @@ -694,7 +709,7 @@ public function outline(Request $request) $this->printGradeStateIconStyles(); echo '
'."\n"; echo '

'.htmlspecialchars(__('Grade Book'))."

\n"; - echo '

Class: '.htmlspecialchars($context_title)."

\n"; + echo $this->classHeading($context_title); $this->printGradeCategoryStyles(); $this->renderGradeNav(array( 'active' => 'outline', @@ -808,7 +823,7 @@ public function classView(Request $request) ?>

-

Class:

+ classHeading($context_title) ?> printGradeCategoryStyles(); $lesson_urls = $this->lessonGradeUrls(); @@ -3713,7 +3728,7 @@ private function beginLessonGradePage($active, $resend_user_id = null) $this->printGradeStateIconStyles(); echo '
'."\n"; echo '

'.htmlspecialchars(__('Grade Book'))."

\n"; - echo '

Class: '.htmlspecialchars($context_title)."

\n"; + echo $this->classHeading($context_title); $this->renderGradeBookChrome($active, $resend_user_id); } diff --git a/lib/src/Controllers/Settings.php b/lib/src/Controllers/Settings.php index c7c6f2a08..131847305 100644 --- a/lib/src/Controllers/Settings.php +++ b/lib/src/Controllers/Settings.php @@ -4009,15 +4009,18 @@ private function importPendingPost($import_url, $action) public static function courseHeadingTitle() { global $CFG, $PDOX; - $title = U::get($_SESSION, 'context_title'); - if ( is_string($title) && trim($title) !== '' ) { - return trim($title); - } $ltiKey = defined('TSUGI_SESSION_LTI') ? TSUGI_SESSION_LTI : 'lti'; - if ( isset($_SESSION[$ltiKey]['context_title']) ) { - $fromLti = $_SESSION[$ltiKey]['context_title']; - if ( is_string($fromLti) && trim($fromLti) !== '' ) { - return trim($fromLti); + $lti = (isset($_SESSION[$ltiKey]) && is_array($_SESSION[$ltiKey])) ? $_SESSION[$ltiKey] : null; + if ( LTIX::launchContextApplies($lti) ) { + $title = U::get($_SESSION, 'context_title'); + if ( is_string($title) && trim($title) !== '' ) { + return trim($title); + } + if ( is_array($lti) && isset($lti['context_title']) ) { + $fromLti = $lti['context_title']; + if ( is_string($fromLti) && trim($fromLti) !== '' ) { + return trim($fromLti); + } } } $context_id = ReqScope::currentContextId(); diff --git a/lib/src/Controllers/Tool.php b/lib/src/Controllers/Tool.php index c882a4b46..193952afd 100644 --- a/lib/src/Controllers/Tool.php +++ b/lib/src/Controllers/Tool.php @@ -11,6 +11,7 @@ use Tsugi\Grades\GradeUtil; use Tsugi\Lumos\Application; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; use Tsugi\Core\Manifest; @@ -224,13 +225,17 @@ public static function requireOutboundLti11LaunchSession(Application $app, $redi */ public static function outboundContextTitle() { global $CFG; + $ltiKey = defined('TSUGI_SESSION_LTI') ? TSUGI_SESSION_LTI : 'lti'; + $lti = (isset($_SESSION[$ltiKey]) && is_array($_SESSION[$ltiKey])) ? $_SESSION[$ltiKey] : null; + if ( ! LTIX::launchContextApplies($lti) ) { + return isset($CFG->context_title) ? $CFG->context_title : ''; + } $title = U::get($_SESSION, 'context_title'); if ( is_string($title) && trim($title) !== '' ) { return $title; } - $ltiKey = defined('TSUGI_SESSION_LTI') ? TSUGI_SESSION_LTI : 'lti'; - if ( isset($_SESSION[$ltiKey]['context_title']) ) { - $title = $_SESSION[$ltiKey]['context_title']; + if ( is_array($lti) && isset($lti['context_title']) ) { + $title = $lti['context_title']; if ( is_string($title) && trim($title) !== '' ) { return $title; } @@ -946,10 +951,50 @@ protected function requireAuth() { die('Must be logged in'); } if ( ! ReqScope::currentContextId() ) { - die('Context required'); + $this->exitCourseRequired(); } } + /** + * A site tool such as grades was opened with no course. + * + * Continue uses this tab's LTI return URL when one is stored, and the + * app home when it is not. + */ + protected function exitCourseRequired() { + $response = new Response($this->courseRequiredHtml(), 403); + $response->headers->set('Content-Type', 'text/html; charset=UTF-8'); + $response->send(); + exit; + } + + /** + * @return string + */ + protected function courseRequiredHtml() { + return self::renderLeavePage(array( + 'title' => __('This page needs a course'), + 'paragraphs' => array( + __('This session does not have a course, so this page cannot open.'), + ), + 'home' => self::appHomeUrl(), + )); + } + + /** + * App home (home_path, else apphome, else wwwroot). + * + * @return string + */ + protected static function appHomeUrl() { + global $CFG; + if ( ! isset($CFG) || ! is_object($CFG) || ! method_exists($CFG, 'getHomeUrl') ) { + return ''; + } + $url = $CFG->getHomeUrl(); + return is_string($url) ? $url : ''; + } + /** * Check if user is instructor/admin, redirect if not * @@ -1284,4 +1329,102 @@ protected function assetUrl($filename, $controllerName = null) { return $this->staticUrl($filename, $controllerName); } + + /** + * Error page with Close Tab and Continue. + * + * Continue starts at the app home. The page script points it at this + * tab's launch return when session storage has one. + * + * @param array{title: string, paragraphs: array, home: string} $copy + * @return string + */ + protected static function renderLeavePage(array $copy) { + global $OUTPUT; + + $inner = '
'; + $inner .= '

'.htmlspecialchars($copy['title']).'

'; + foreach ( $copy['paragraphs'] as $paragraph ) { + $inner .= '

'.htmlspecialchars($paragraph).'

'; + } + $continueHidden = ($copy['home'] === '') ? ' style="display:none"' : ''; + $inner .= '

'; + $inner .= ' '; + $inner .= '' + .htmlspecialchars(__('Continue')).''; + $inner .= '

'; + $detail = isset($copy['paragraphs'][0]) ? $copy['paragraphs'][0] : $copy['title']; + $inner .= self::leavePageReturnScript($detail); + $inner .= '
'; + + if ( isset($OUTPUT) && is_object($OUTPUT) && method_exists($OUTPUT, 'header') ) { + $was = $OUTPUT->buffer; + $OUTPUT->buffer = true; + try { + $html = $OUTPUT->header(); + $html .= $OUTPUT->bodyStart(false); + $html .= $OUTPUT->topNav(); + $html .= $inner; + $html .= $OUTPUT->footer(); + $OUTPUT->buffer = $was; + if ( is_string($html) && $html !== '' ) { + return $html; + } + } catch ( \Throwable $e ) { + $OUTPUT->buffer = $was; + } + } + + return '' + .htmlspecialchars($copy['title']).''.$inner.''; + } + + /** + * Point Continue at this tab's launch return before footer scripts + * replace TSUGI_SESSION_DATA with the current page. + * + * Same read and preference as Output::htmlError: error return, then return URL. + * + * @param string $detail + * @return string + */ + protected static function leavePageReturnScript($detail) { + $detail = is_string($detail) ? $detail : ''; + $err = json_encode($detail); + return ''; + } } diff --git a/lib/src/Core/LTIX.php b/lib/src/Core/LTIX.php index 834cb0a80..52fe6590f 100644 --- a/lib/src/Core/LTIX.php +++ b/lib/src/Core/LTIX.php @@ -2047,12 +2047,17 @@ public static function requireDataPrivate($needed=self::ALL, $_SESSION['HEARTBEAT_COUNT'] = 0; // Site URLs (buildmenu): leave leftover /courses/{id} sandbox, use Google-login course. + // A non-Google site URL is not the stored launch course. Drop the top-level + // course fields. The launch row stays for a later /courses/{id} match. if ( class_exists('\Tsugi\Controllers\Courses') ) { \Tsugi\Controllers\Courses::restoreSiteLoginContext(); + \Tsugi\Controllers\Courses::releaseUnmountedCourse(); } $LTI = $_SESSION[TSUGI_SESSION_LTI] ?? null; - self::rejectDeletedContext($PDOX, $LTI); + if ( self::launchContextApplies(is_array($LTI) ? $LTI : null) ) { + self::rejectDeletedContext($PDOX, $LTI); + } if ( count($needed) == 0 && $LTI === null ) { self::provisionReqScope(null); return $TSUGI_LAUNCH; @@ -2070,6 +2075,34 @@ public static function requireDataPrivate($needed=self::ALL, return $launch; } + /** + * True when this request's course is the course stored on the launch row. + * + * A cookieless tool launch is that row. A cookie /courses/{id} request + * matches when the URL id is the row's context_id. Any other cookie URL + * matches only when $_SESSION['context_id'] is that same id. A request + * course of 0 does not match. + * + * @param mixed $LTI + */ + public static function launchContextApplies($LTI) { + $ltiContext = is_array($LTI) ? (int) ($LTI['context_id'] ?? 0) : 0; + if ( ! defined('COOKIE_SESSION') ) { + return $ltiContext > 0; + } + $requestContext = 0; + if ( class_exists('\Tsugi\Controllers\Courses') + && \Tsugi\Controllers\Courses::isCourseMountedRequest() ) { + $requestContext = \Tsugi\Controllers\Courses::courseIdFromRequest(); + } else { + $requestContext = (int) ($_SESSION['context_id'] ?? 0); + } + if ( $ltiContext < 1 ) { + return $requestContext > 0; + } + return $requestContext === $ltiContext; + } + /** * Fill ReqScope from the open session without changing launch globals. * @@ -2105,17 +2138,19 @@ private static function provisionReqScope($LTI) { if ( isset($_SESSION['id']) ) { $user_id = (int) $_SESSION['id']; } - if ( isset($_SESSION['context_id']) ) { - $context_id = (int) $_SESSION['context_id']; - } if ( $user_id < 1 && $lti_user > 0 ) { $user_id = $lti_user; - $context_id = $lti_context; - if ( $lti_link > 0 ) { + } + if ( self::launchContextApplies($LTI) ) { + if ( class_exists('\Tsugi\Controllers\Courses') + && \Tsugi\Controllers\Courses::isCourseMountedRequest() ) { + $context_id = \Tsugi\Controllers\Courses::courseIdFromRequest(); + } else if ( isset($_SESSION['context_id']) ) { + $context_id = (int) $_SESSION['context_id']; + } + if ( $lti_link > 0 && $lti_context > 0 && $lti_context === $context_id ) { $link_id = $lti_link; } - } else if ( $lti_link > 0 && $lti_context > 0 && $lti_context === $context_id ) { - $link_id = $lti_link; } } $post = $_SESSION[TSUGI_SESSION_LTI_POST] ?? null; @@ -2245,7 +2280,9 @@ public static function buildLaunch($LTI) { $TSUGI_LAUNCH->key = $TSUGI_KEY; } - if ( isset($LTI['context_id']) && ! is_object($CONTEXT) ) { + $courseApplies = self::launchContextApplies(is_array($LTI) ? $LTI : null); + + if ( $courseApplies && isset($LTI['context_id']) && ! is_object($CONTEXT) ) { $CONTEXT = new \Tsugi\Core\Context(); $CONTEXT->launch = $TSUGI_LAUNCH; $CONTEXT->id = $LTI['context_id']; @@ -2256,7 +2293,7 @@ public static function buildLaunch($LTI) { $TSUGI_LAUNCH->context = $CONTEXT; } - if ( isset($LTI['link_id']) && ! is_object($LINK) ) { + if ( $courseApplies && isset($LTI['link_id']) && ! is_object($LINK) ) { $LINK = new \Tsugi\Core\Link(); $LINK->launch = $TSUGI_LAUNCH; $LINK->id = $LTI['link_id']; @@ -2288,7 +2325,7 @@ public static function buildLaunch($LTI) { $TSUGI_LAUNCH->link = $LINK; } - if ( isset($LTI['result_id']) && ! is_object($RESULT) ) { + if ( $courseApplies && isset($LTI['result_id']) && ! is_object($RESULT) ) { $RESULT = new \Tsugi\Core\Result(); $RESULT->launch = $TSUGI_LAUNCH; $RESULT->id = $LTI['result_id']; @@ -2296,7 +2333,7 @@ public static function buildLaunch($LTI) { $TSUGI_LAUNCH->result = $RESULT; } - if ( $TSUGI_LAUNCH->ltiRawParameter(LTIConstants::EXT_CONTEXT_MEMBERSHIP_ID) && ! is_object($ROSTER)) { + if ( $courseApplies && $TSUGI_LAUNCH->ltiRawParameter(LTIConstants::EXT_CONTEXT_MEMBERSHIP_ID) && ! is_object($ROSTER)) { $ROSTER = new \Tsugi\Core\Roster(); $ROSTER->id = $TSUGI_LAUNCH->ltiRawParameter(LTIConstants::EXT_CONTEXT_MEMBERSHIP_ID); $ROSTER->url = $TSUGI_LAUNCH->ltiRawParameter(LTIConstants::EXT_CONTEXT_MEMBERSHIP_URL); diff --git a/lib/src/Core/Manifest.php b/lib/src/Core/Manifest.php index f8cf91aad..9abf9e76b 100644 --- a/lib/src/Core/Manifest.php +++ b/lib/src/Core/Manifest.php @@ -743,9 +743,10 @@ public static function idForContext($context_id) { * session still has no manifest_id from Google login. */ public static function resolvedId() { - // Site /lessons with a Plus (or other non-Google) cookie must stay on - // $CFG->lessons. Course URLs still read the manifest. - if ( self::siteLessonsIgnoreManifest() ) { + $contextId = ReqScope::currentContextIdLegacy(); + // A request with no course has no manifest. Drop a leftover session id. + if ( $contextId < 1 ) { + self::rememberInSession(0); return 0; } $id = self::activeId(); @@ -759,26 +760,6 @@ public static function resolvedId() { return $id; } - /** - * True on site /lessons when this cookie is not a Google login. - * - * A Plus launch stores its manifest in the shared cookie. /courses/{id}/lessons - * still belongs to that course. Cookieless tool launches are not this path. - */ - private static function siteLessonsIgnoreManifest() { - if ( ! class_exists('\Tsugi\Controllers\Courses') || ! class_exists('\Tsugi\Controllers\Tool') ) { - return false; - } - if ( \Tsugi\Controllers\Courses::isCourseMountedRequest() ) { - return false; - } - if ( \Tsugi\Controllers\Courses::isGoogleLoginSession() ) { - return false; - } - $path = \Tsugi\Controllers\Tool::requestPath(); - return (bool) preg_match('#/lessons(?:_launch)?(?:/|$)#', $path); - } - /** * Copy manifest_id into the session LTI blob (integer only). */ diff --git a/lib/tests/Controllers/CoursesControllerTest.php b/lib/tests/Controllers/CoursesControllerTest.php index a080e32e0..11b8b475a 100644 --- a/lib/tests/Controllers/CoursesControllerTest.php +++ b/lib/tests/Controllers/CoursesControllerTest.php @@ -8,6 +8,11 @@ require_once "src/Lumos/Router.php"; require_once "src/Util/U.php"; +$rootAutoload = dirname(__DIR__, 3) . '/vendor/autoload.php'; +if ( file_exists($rootAutoload) ) { + require_once $rootAutoload; +} + use \Tsugi\Controllers\Courses; use \Tsugi\Core\Manifest; use \Tsugi\Core\ReqScope; @@ -562,4 +567,90 @@ public function testReleaseContextAfterDeleteClearsDeletedSiteCourse() $this->assertArrayNotHasKey('context_id', $_SESSION['lti']); $this->assertArrayNotHasKey('manifest_id', $_SESSION['lti']); } + + public function testReleaseUnmountedCourseKeepsLaunchRow() + { + if ( ! defined('COOKIE_SESSION') ) { + define('COOKIE_SESSION', true); + } + $previous = $_SERVER['REQUEST_URI'] ?? null; + $_SERVER['REQUEST_URI'] = '/grades'; + $_SESSION['context_id'] = 10; + $_SESSION['context_title'] = 'Tsugi Plus 01'; + $_SESSION['context_key'] = 'abc'; + $_SESSION['isinstructor'] = true; + $_SESSION['manifest_id'] = 299; + $_SESSION['lti_post'] = array('user_id' => 'x'); + $_SESSION['lti'] = array( + 'context_id' => 10, + 'context_title' => 'Tsugi Plus 01', + 'manifest_id' => 299, + 'user_id' => 5, + ); + + $this->assertTrue(Courses::releaseUnmountedCourse()); + $this->assertArrayNotHasKey('context_id', $_SESSION); + $this->assertArrayNotHasKey('context_title', $_SESSION); + $this->assertArrayNotHasKey('isinstructor', $_SESSION); + $this->assertSame(10, $_SESSION['lti']['context_id']); + $this->assertSame('Tsugi Plus 01', $_SESSION['lti']['context_title']); + $this->assertSame(5, $_SESSION['lti']['user_id']); + $this->assertSame(0, Manifest::activeId()); + $this->assertFalse(\Tsugi\Core\LTIX::launchContextApplies($_SESSION['lti'])); + + $_SERVER['REQUEST_URI'] = '/courses/10/lessons'; + $this->assertTrue(\Tsugi\Core\LTIX::launchContextApplies($_SESSION['lti'])); + $this->assertFalse(Courses::releaseUnmountedCourse()); + + $_SERVER['REQUEST_URI'] = '/grades'; + $_SESSION['oauth_consumer_key'] = 'google.com'; + unset($_SESSION['lti_post']); + $_SESSION['context_id'] = 36; + $_SESSION['context_title'] = 'Site'; + $_SESSION['lti']['context_id'] = 36; + $_SESSION['lti']['key_key'] = 'google.com'; + $this->assertFalse(Courses::releaseUnmountedCourse()); + $this->assertSame(36, $_SESSION['context_id']); + $this->assertSame('Site', $_SESSION['context_title']); + + if ( $previous === null ) { + unset($_SERVER['REQUEST_URI']); + } else { + $_SERVER['REQUEST_URI'] = $previous; + } + } + + public function testMembershipFailureExplainsSessionChanged() + { + if ( ! function_exists('__') ) { + require_once __DIR__.'/../../include/setup_i18n.php'; + } + $method = new \ReflectionMethod(Courses::class, 'switchFailedResponse'); + $response = $method->invoke(null, 'You are not a member of that course.'); + $this->assertSame(403, $response->getStatusCode()); + $body = $response->getContent(); + $this->assertStringContainsString('This session cannot open this course', $body); + $this->assertStringContainsString( + 'This session may have been logged in or logged out on another tab, and the new session no longer has access to this course.', + $body + ); + $this->assertStringContainsString('TSUGI_SESSION_DATA', $body); + $this->assertStringContainsString('launch_presentation_error_return_url', $body); + $this->assertStringContainsString('Close Tab', $body); + $this->assertStringContainsString('>Continue<', $body); + $this->assertStringNotContainsString('You are not a member of that course.', $body); + } + + public function testCourseSwitchFailureRendersTheMessage() + { + if ( ! function_exists('__') ) { + require_once __DIR__.'/../../include/setup_i18n.php'; + } + $method = new \ReflectionMethod(Courses::class, 'switchFailedResponse'); + $response = $method->invoke(null, 'Course not found.'); + $this->assertSame(400, $response->getStatusCode()); + $body = $response->getContent(); + $this->assertStringContainsString('Cannot open this course', $body); + $this->assertStringContainsString('Course not found.', $body); + } } diff --git a/lib/tests/Controllers/ToolControllerTest.php b/lib/tests/Controllers/ToolControllerTest.php index 1f1d222fb..6edeb319d 100644 --- a/lib/tests/Controllers/ToolControllerTest.php +++ b/lib/tests/Controllers/ToolControllerTest.php @@ -40,6 +40,7 @@ public function exposeExternalLinkAttrs(string $url, string $linkLabel = 'Learn } public function exposeIsAdmin(): bool { return $this->isAdmin(); } public function exposeRequireAuth(): void { $this->requireAuth(); } + public function exposeCourseRequiredHtml(): string { return $this->courseRequiredHtml(); } public function exposeLmsAnalyticsPath(): string { return $this->lmsAnalyticsPath(); } public function exposeLmsAnalyticsKey(?string $path = null): string { return $this->lmsAnalyticsKey($path); } public function exposeStaticUrl(string $filename, ?string $controllerName = null): string { @@ -475,6 +476,21 @@ public function testRequireAuthPassesWhenSessionSet(): void $this->assertTrue(true); // if we get here, requireAuth didn't die } + public function testCourseRequiredPageOffersContinue(): void + { + if ( ! function_exists('__') ) { + require_once __DIR__.'/../../include/setup_i18n.php'; + } + $tool = $this->createAnnouncementsStub(); + $html = $tool->exposeCourseRequiredHtml(); + $this->assertStringContainsString('This page needs a course', $html); + $this->assertStringContainsString('This session does not have a course, so this page cannot open.', $html); + $this->assertStringContainsString('Close Tab', $html); + $this->assertStringContainsString('>Continue<', $html); + $this->assertStringContainsString('launch_presentation_error_return_url', $html); + $this->assertStringContainsString('https://local.ca4e.com', $html); + } + // --- lmsAnalyticsPath tests --- public function testLmsAnalyticsPathFromScriptName(): void diff --git a/lib/tests/Core/ManifestTest.php b/lib/tests/Core/ManifestTest.php index 00978d5f2..60796b6e1 100644 --- a/lib/tests/Core/ManifestTest.php +++ b/lib/tests/Core/ManifestTest.php @@ -114,6 +114,9 @@ public function testIdForContextZeroWithoutContext() public function testResolvedIdUsesSession() { $this->assertSame(0, Manifest::resolvedId()); + $_SESSION['id'] = 1; + $_SESSION['context_id'] = 42; + \Tsugi\Core\ReqScope::resetIdentity(); Manifest::rememberInSession(12); $this->assertSame(12, Manifest::resolvedId()); } @@ -164,6 +167,9 @@ public function testCachedRowServesJsonAndThemeWithoutDatabase() 'title' => 'Electric Course', ); Manifest::setMCache($fake); + $_SESSION['id'] = 1; + $_SESSION['context_id'] = 42; + \Tsugi\Core\ReqScope::resetIdentity(); Manifest::rememberInSession(77); $this->assertSame($json, Manifest::loadJson(77)); @@ -191,6 +197,9 @@ public function testCurrentLessonsPrefersManifestOverFile() $fake->enabled = true; $fake->store[Manifest::cacheKey(99)] = $json; Manifest::setMCache($fake); + $_SESSION['id'] = 1; + $_SESSION['context_id'] = 42; + \Tsugi\Core\ReqScope::resetIdentity(); Manifest::rememberInSession(99); $l = Manifest::currentLessons(); @@ -209,29 +218,15 @@ public function testCurrentLessonsFallsBackToFile() $this->assertSame('Python for Everybody (PY4E)', $l->lessons->title); } - public function testSiteLessonsIgnoresManifestWhenNotGoogleLogin() + public function testResolvedIdClearsManifestWhenContextIsZero() { - global $CFG; $previous = $_SERVER['REQUEST_URI'] ?? null; $_SERVER['REQUEST_URI'] = '/lessons'; - $CFG->lessons = __DIR__ . '/../fixtures/lessons/py4e-modern-lessons-items.json'; - $_SESSION['lti_post'] = array('oauth_nonce' => 'plus'); - $_SESSION['oauth_consumer_key'] = 'sakai.plus'; Manifest::rememberInSession(99); $this->assertSame(99, Manifest::activeId()); $this->assertSame(0, Manifest::resolvedId()); - $l = Manifest::currentLessons(); - $this->assertInstanceOf(\Tsugi\Services\Lessons\LessonsService::class, $l); - $this->assertSame('Python for Everybody (PY4E)', $l->lessons->title); - - $_SERVER['REQUEST_URI'] = '/courses/42/lessons'; - $this->assertSame(99, Manifest::resolvedId()); - - $_SERVER['REQUEST_URI'] = '/lessons'; - unset($_SESSION['lti_post']); - $_SESSION['oauth_consumer_key'] = 'google.com'; - $this->assertSame(99, Manifest::resolvedId()); + $this->assertSame(0, Manifest::activeId()); if ( $previous === null ) { unset($_SERVER['REQUEST_URI']);