mirror of
https://github.com/moodle/moodle.git
synced 2025-08-05 00:46:50 +02:00
MDL-46099 session: fix use of references for session globals
This reverses the references used for global $USER and $SESSION, the reason is that PHP does not allow references to references. $USER is a reference to $GLOBALS['USER'] which means we cannot put any references to it. Solution is to store the current user and session objects in $GLOBALS['USER'] and $GLOBALS['SESSIOn'] are reference them in $_SESSION. This patch makes the session code behave the same way in CLI, phpunit and normal web requests - this allows use to finally unit test most aspects of the session code in Moodle.
This commit is contained in:
parent
5c1049f72b
commit
d0dd8d33bb
10 changed files with 428 additions and 87 deletions
|
@ -201,17 +201,9 @@ if (defined('COMPONENT_CLASSLOADER')) {
|
||||||
require($CFG->dirroot.'/version.php');
|
require($CFG->dirroot.'/version.php');
|
||||||
$CFG->target_release = $release;
|
$CFG->target_release = $release;
|
||||||
|
|
||||||
$_SESSION = array();
|
\core\session\manager::init_empty_session();
|
||||||
$_SESSION['SESSION'] = new stdClass();
|
|
||||||
$_SESSION['SESSION']->lang = $CFG->lang;
|
|
||||||
$_SESSION['USER'] = new stdClass();
|
|
||||||
$_SESSION['USER']->id = 0;
|
|
||||||
$_SESSION['USER']->mnethostid = 1;
|
|
||||||
|
|
||||||
global $SESSION;
|
global $SESSION;
|
||||||
global $USER;
|
global $USER;
|
||||||
$SESSION = &$_SESSION['SESSION'];
|
|
||||||
$USER = &$_SESSION['USER'];
|
|
||||||
|
|
||||||
global $COURSE;
|
global $COURSE;
|
||||||
$COURSE = new stdClass();
|
$COURSE = new stdClass();
|
||||||
|
|
10
install.php
10
install.php
|
@ -233,17 +233,9 @@ if (defined('COMPONENT_CLASSLOADER')) {
|
||||||
require('version.php');
|
require('version.php');
|
||||||
$CFG->target_release = $release;
|
$CFG->target_release = $release;
|
||||||
|
|
||||||
$_SESSION = array();
|
\core\session\manager::init_empty_session();
|
||||||
$_SESSION['SESSION'] = new stdClass();
|
|
||||||
$_SESSION['SESSION']->lang = $CFG->lang;
|
|
||||||
$_SESSION['USER'] = new stdClass();
|
|
||||||
$_SESSION['USER']->id = 0;
|
|
||||||
$_SESSION['USER']->mnethostid = 1;
|
|
||||||
|
|
||||||
global $SESSION;
|
global $SESSION;
|
||||||
global $USER;
|
global $USER;
|
||||||
$SESSION = &$_SESSION['SESSION'];
|
|
||||||
$USER = &$_SESSION['USER'];
|
|
||||||
|
|
||||||
global $COURSE;
|
global $COURSE;
|
||||||
$COURSE = new stdClass();
|
$COURSE = new stdClass();
|
||||||
|
|
|
@ -79,6 +79,16 @@ class manager {
|
||||||
self::initialise_user_session($newsid);
|
self::initialise_user_session($newsid);
|
||||||
self::check_security();
|
self::check_security();
|
||||||
|
|
||||||
|
// Link global $USER and $SESSION,
|
||||||
|
// this is tricky because PHP does not allow references to references
|
||||||
|
// and global keyword uses internally once reference to the $GLOBALS array.
|
||||||
|
// The solution is to use the $GLOBALS['USER'] and $GLOBALS['$SESSION']
|
||||||
|
// as the main storage of data and put references to $_SESSION.
|
||||||
|
$GLOBALS['USER'] = $_SESSION['USER'];
|
||||||
|
$_SESSION['USER'] =& $GLOBALS['USER'];
|
||||||
|
$GLOBALS['SESSION'] = $_SESSION['SESSION'];
|
||||||
|
$_SESSION['SESSION'] =& $GLOBALS['SESSION'];
|
||||||
|
|
||||||
} catch (\Exception $ex) {
|
} catch (\Exception $ex) {
|
||||||
@session_write_close();
|
@session_write_close();
|
||||||
self::init_empty_session();
|
self::init_empty_session();
|
||||||
|
@ -139,31 +149,29 @@ class manager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty current session, fill it with not-logged-in user info.
|
* Empty current session, fill it with not-logged-in user info.
|
||||||
|
*
|
||||||
|
* This is intended for installation scripts, unit tests and other
|
||||||
|
* special areas. Do NOT use for logout and session termination
|
||||||
|
* in normal requests!
|
||||||
*/
|
*/
|
||||||
protected static function init_empty_session() {
|
public static function init_empty_session() {
|
||||||
global $CFG;
|
global $CFG;
|
||||||
|
|
||||||
// Session not used at all.
|
$GLOBALS['SESSION'] = new \stdClass();
|
||||||
$_SESSION = array();
|
|
||||||
$_SESSION['SESSION'] = new \stdClass();
|
$GLOBALS['USER'] = new \stdClass();
|
||||||
$_SESSION['USER'] = new \stdClass();
|
$GLOBALS['USER']->id = 0;
|
||||||
$_SESSION['USER']->id = 0;
|
|
||||||
if (isset($CFG->mnet_localhost_id)) {
|
if (isset($CFG->mnet_localhost_id)) {
|
||||||
$_SESSION['USER']->mnethostid = $CFG->mnet_localhost_id;
|
$GLOBALS['USER']->mnethostid = $CFG->mnet_localhost_id;
|
||||||
} else {
|
} else {
|
||||||
// Not installed yet, the future host id will be most probably 1.
|
// Not installed yet, the future host id will be most probably 1.
|
||||||
$_SESSION['USER']->mnethostid = 1;
|
$GLOBALS['USER']->mnethostid = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PHPUNIT_TEST or defined('BEHAT_TEST')) {
|
// Link global $USER and $SESSION.
|
||||||
// Phpunit tests and behat init use reversed reference,
|
$_SESSION = array();
|
||||||
// the reason is we can not point global to $_SESSION outside of global scope.
|
$_SESSION['USER'] =& $GLOBALS['USER'];
|
||||||
global $USER, $SESSION;
|
$_SESSION['SESSION'] =& $GLOBALS['SESSION'];
|
||||||
$USER = $_SESSION['USER'];
|
|
||||||
$SESSION = $_SESSION['SESSION'];
|
|
||||||
$_SESSION['USER'] =& $USER;
|
|
||||||
$_SESSION['SESSION'] =& $SESSION;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -249,9 +257,11 @@ class manager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise $USER and $SESSION objects, handles google access
|
* Initialise $_SESSION, handles google access
|
||||||
* and sets up not-logged-in user properly.
|
* and sets up not-logged-in user properly.
|
||||||
*
|
*
|
||||||
|
* WARNING: $USER and $SESSION are set up later, do not use them yet!
|
||||||
|
*
|
||||||
* @param bool $newsid is this a new session in first http request?
|
* @param bool $newsid is this a new session in first http request?
|
||||||
*/
|
*/
|
||||||
protected static function initialise_user_session($newsid) {
|
protected static function initialise_user_session($newsid) {
|
||||||
|
@ -416,6 +426,8 @@ class manager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Do various session security checks.
|
* Do various session security checks.
|
||||||
|
*
|
||||||
|
* WARNING: $USER and $SESSION are set up later, do not use them yet!
|
||||||
*/
|
*/
|
||||||
protected static function check_security() {
|
protected static function check_security() {
|
||||||
global $CFG;
|
global $CFG;
|
||||||
|
@ -489,7 +501,7 @@ class manager {
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$DB->delete_records('sessions', array('sid'=>$sid));
|
$DB->delete_records('sessions', array('sid'=>$sid));
|
||||||
self::init_empty_session();
|
self::init_empty_session();
|
||||||
self::add_session_record($_SESSION['USER']->id);
|
self::add_session_record($_SESSION['USER']->id); // Do not use $USER here because it may not be set up yet.
|
||||||
session_write_close();
|
session_write_close();
|
||||||
self::$sessionactive = false;
|
self::$sessionactive = false;
|
||||||
}
|
}
|
||||||
|
@ -590,22 +602,19 @@ class manager {
|
||||||
* @param \stdClass $user record
|
* @param \stdClass $user record
|
||||||
*/
|
*/
|
||||||
public static function set_user(\stdClass $user) {
|
public static function set_user(\stdClass $user) {
|
||||||
$_SESSION['USER'] = $user;
|
$GLOBALS['USER'] = $user;
|
||||||
unset($_SESSION['USER']->description); // Conserve memory.
|
unset($GLOBALS['USER']->description); // Conserve memory.
|
||||||
unset($_SESSION['USER']->password); // Improve security.
|
unset($GLOBALS['USER']->password); // Improve security.
|
||||||
if (isset($_SESSION['USER']->lang)) {
|
if (isset($GLOBALS['USER']->lang)) {
|
||||||
// Make sure it is a valid lang pack name.
|
// Make sure it is a valid lang pack name.
|
||||||
$_SESSION['USER']->lang = clean_param($_SESSION['USER']->lang, PARAM_LANG);
|
$GLOBALS['USER']->lang = clean_param($GLOBALS['USER']->lang, PARAM_LANG);
|
||||||
}
|
}
|
||||||
sesskey(); // Init session key.
|
|
||||||
|
|
||||||
if (PHPUNIT_TEST or defined('BEHAT_TEST')) {
|
// Relink session with global $USER just in case it got unlinked somehow.
|
||||||
// Phpunit tests and behat init use reversed reference,
|
$_SESSION['USER'] =& $GLOBALS['USER'];
|
||||||
// the reason is we can not point global to $_SESSION outside of global scope.
|
|
||||||
global $USER;
|
// Init session key.
|
||||||
$USER = $_SESSION['USER'];
|
sesskey();
|
||||||
$_SESSION['USER'] =& $USER;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -697,7 +706,7 @@ class manager {
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function is_loggedinas() {
|
public static function is_loggedinas() {
|
||||||
return !empty($_SESSION['USER']->realuser);
|
return !empty($GLOBALS['USER']->realuser);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -708,7 +717,7 @@ class manager {
|
||||||
if (self::is_loggedinas()) {
|
if (self::is_loggedinas()) {
|
||||||
return $_SESSION['REALUSER'];
|
return $_SESSION['REALUSER'];
|
||||||
} else {
|
} else {
|
||||||
return $_SESSION['USER'];
|
return $GLOBALS['USER'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -725,12 +734,14 @@ class manager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to fresh new $SESSION.
|
// Switch to fresh new $_SESSION.
|
||||||
$_SESSION['REALSESSION'] = $_SESSION['SESSION'];
|
$_SESSION = array();
|
||||||
$_SESSION['SESSION'] = new \stdClass();
|
$_SESSION['REALSESSION'] = clone($GLOBALS['SESSION']);
|
||||||
|
$GLOBALS['SESSION'] = new \stdClass();
|
||||||
|
$_SESSION['SESSION'] =& $GLOBALS['SESSION'];
|
||||||
|
|
||||||
// Create the new $USER object with all details and reload needed capabilities.
|
// Create the new $USER object with all details and reload needed capabilities.
|
||||||
$_SESSION['REALUSER'] = $_SESSION['USER'];
|
$_SESSION['REALUSER'] = clone($GLOBALS['USER']);
|
||||||
$user = get_complete_user_data('id', $userid);
|
$user = get_complete_user_data('id', $userid);
|
||||||
$user->realuser = $_SESSION['REALUSER']->id;
|
$user->realuser = $_SESSION['REALUSER']->id;
|
||||||
$user->loginascontext = $context;
|
$user->loginascontext = $context;
|
||||||
|
|
|
@ -189,14 +189,9 @@ class phpunit_util extends testing_util {
|
||||||
$FULLME = null;
|
$FULLME = null;
|
||||||
$ME = null;
|
$ME = null;
|
||||||
$SCRIPT = null;
|
$SCRIPT = null;
|
||||||
$SESSION = new stdClass();
|
|
||||||
$_SESSION['SESSION'] =& $SESSION;
|
|
||||||
|
|
||||||
// set fresh new not-logged-in user
|
// Empty sessison and set fresh new not-logged-in user.
|
||||||
$user = new stdClass();
|
\core\session\manager::init_empty_session();
|
||||||
$user->id = 0;
|
|
||||||
$user->mnethostid = $CFG->mnet_localhost_id;
|
|
||||||
\core\session\manager::set_user($user);
|
|
||||||
|
|
||||||
// reset all static caches
|
// reset all static caches
|
||||||
\core\event\manager::phpunit_reset();
|
\core\event\manager::phpunit_reset();
|
||||||
|
|
|
@ -76,6 +76,7 @@ class core_phpunit_advanced_testcase extends advanced_testcase {
|
||||||
|
|
||||||
$this->assertEquals(0, $USER->id);
|
$this->assertEquals(0, $USER->id);
|
||||||
$this->assertSame($_SESSION['USER'], $USER);
|
$this->assertSame($_SESSION['USER'], $USER);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
$user = $DB->get_record('user', array('id'=>2));
|
$user = $DB->get_record('user', array('id'=>2));
|
||||||
$this->assertNotEmpty($user);
|
$this->assertNotEmpty($user);
|
||||||
|
@ -83,26 +84,31 @@ class core_phpunit_advanced_testcase extends advanced_testcase {
|
||||||
$this->assertEquals(2, $USER->id);
|
$this->assertEquals(2, $USER->id);
|
||||||
$this->assertEquals(2, $_SESSION['USER']->id);
|
$this->assertEquals(2, $_SESSION['USER']->id);
|
||||||
$this->assertSame($_SESSION['USER'], $USER);
|
$this->assertSame($_SESSION['USER'], $USER);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
$USER->id = 3;
|
$USER->id = 3;
|
||||||
$this->assertEquals(3, $USER->id);
|
$this->assertEquals(3, $USER->id);
|
||||||
$this->assertEquals(3, $_SESSION['USER']->id);
|
$this->assertEquals(3, $_SESSION['USER']->id);
|
||||||
$this->assertSame($_SESSION['USER'], $USER);
|
$this->assertSame($_SESSION['USER'], $USER);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
\core\session\manager::set_user($user);
|
\core\session\manager::set_user($user);
|
||||||
$this->assertEquals(2, $USER->id);
|
$this->assertEquals(2, $USER->id);
|
||||||
$this->assertEquals(2, $_SESSION['USER']->id);
|
$this->assertEquals(2, $_SESSION['USER']->id);
|
||||||
$this->assertSame($_SESSION['USER'], $USER);
|
$this->assertSame($_SESSION['USER'], $USER);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
$USER = $DB->get_record('user', array('id'=>1));
|
$USER = $DB->get_record('user', array('id'=>1));
|
||||||
$this->assertNotEmpty($USER);
|
$this->assertNotEmpty($USER);
|
||||||
$this->assertEquals(1, $USER->id);
|
$this->assertEquals(1, $USER->id);
|
||||||
$this->assertEquals(1, $_SESSION['USER']->id);
|
$this->assertEquals(1, $_SESSION['USER']->id);
|
||||||
$this->assertSame($_SESSION['USER'], $USER);
|
$this->assertSame($_SESSION['USER'], $USER);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
$this->setUser(null);
|
$this->setUser(null);
|
||||||
$this->assertEquals(0, $USER->id);
|
$this->assertEquals(0, $USER->id);
|
||||||
$this->assertSame($_SESSION['USER'], $USER);
|
$this->assertSame($_SESSION['USER'], $USER);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_set_admin_user() {
|
public function test_set_admin_user() {
|
||||||
|
|
|
@ -37,7 +37,10 @@ function sesskey() {
|
||||||
// note: do not use $USER because it may not be initialised yet
|
// note: do not use $USER because it may not be initialised yet
|
||||||
if (empty($_SESSION['USER']->sesskey)) {
|
if (empty($_SESSION['USER']->sesskey)) {
|
||||||
if (!isset($_SESSION['USER'])) {
|
if (!isset($_SESSION['USER'])) {
|
||||||
$_SESSION['USER'] = new stdClass;
|
// This should never happen,
|
||||||
|
// do not mess with session and globals here,
|
||||||
|
// let any checks fail instead!
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
$_SESSION['USER']->sesskey = random_string(10);
|
$_SESSION['USER']->sesskey = random_string(10);
|
||||||
}
|
}
|
||||||
|
@ -151,16 +154,28 @@ function get_moodle_cookie() {
|
||||||
* Sets up current user and course environment (lang, etc.) in cron.
|
* Sets up current user and course environment (lang, etc.) in cron.
|
||||||
* Do not use outside of cron script!
|
* Do not use outside of cron script!
|
||||||
*
|
*
|
||||||
* @param stdClass $user full user object, null means default cron user (admin)
|
* @param stdClass $user full user object, null means default cron user (admin),
|
||||||
* @param $course full course record, null means $SITE
|
* value 'reset' means reset internal static caches.
|
||||||
|
* @param stdClass $course full course record, null means $SITE
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
function cron_setup_user($user = NULL, $course = NULL) {
|
function cron_setup_user($user = NULL, $course = NULL) {
|
||||||
global $CFG, $SITE, $PAGE;
|
global $CFG, $SITE, $PAGE;
|
||||||
|
|
||||||
|
if (!CLI_SCRIPT) {
|
||||||
|
throw new coding_exception('Function cron_setup_user() cannot be used in normal requests!');
|
||||||
|
}
|
||||||
|
|
||||||
static $cronuser = NULL;
|
static $cronuser = NULL;
|
||||||
static $cronsession = NULL;
|
static $cronsession = NULL;
|
||||||
|
|
||||||
|
if ($user === 'reset') {
|
||||||
|
$cronuser = null;
|
||||||
|
$cronsession = null;
|
||||||
|
\core\session\manager::init_empty_session();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($cronuser)) {
|
if (empty($cronuser)) {
|
||||||
/// ignore admins timezone, language and locale - use site default instead!
|
/// ignore admins timezone, language and locale - use site default instead!
|
||||||
$cronuser = get_admin();
|
$cronuser = get_admin();
|
||||||
|
@ -173,15 +188,16 @@ function cron_setup_user($user = NULL, $course = NULL) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
// cached default cron user (==modified admin for now)
|
// Cached default cron user (==modified admin for now).
|
||||||
|
\core\session\manager::init_empty_session();
|
||||||
\core\session\manager::set_user($cronuser);
|
\core\session\manager::set_user($cronuser);
|
||||||
$_SESSION['SESSION'] = $cronsession;
|
$GLOBALS['SESSION'] = $cronsession;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// emulate real user session - needed for caps in cron
|
// Emulate real user session - needed for caps in cron.
|
||||||
if ($_SESSION['USER']->id != $user->id) {
|
if ($GLOBALS['USER']->id != $user->id) {
|
||||||
|
\core\session\manager::init_empty_session();
|
||||||
\core\session\manager::set_user($user);
|
\core\session\manager::set_user($user);
|
||||||
$_SESSION['SESSION'] = new stdClass();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -804,10 +804,6 @@ if (empty($CFG->sessiontimeout)) {
|
||||||
$CFG->sessiontimeout = 7200;
|
$CFG->sessiontimeout = 7200;
|
||||||
}
|
}
|
||||||
\core\session\manager::start();
|
\core\session\manager::start();
|
||||||
if (!PHPUNIT_TEST and !defined('BEHAT_TEST')) {
|
|
||||||
$SESSION =& $_SESSION['SESSION'];
|
|
||||||
$USER =& $_SESSION['USER'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialise some variables that are supposed to be set in config.php only.
|
// Initialise some variables that are supposed to be set in config.php only.
|
||||||
if (!isset($CFG->filelifetime)) {
|
if (!isset($CFG->filelifetime)) {
|
||||||
|
|
|
@ -189,9 +189,7 @@ class behat_hooks extends behat_base {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset $SESSION.
|
// Reset $SESSION.
|
||||||
$_SESSION = array();
|
\core\session\manager::init_empty_session();
|
||||||
$SESSION = new stdClass();
|
|
||||||
$_SESSION['SESSION'] =& $SESSION;
|
|
||||||
|
|
||||||
behat_util::reset_database();
|
behat_util::reset_database();
|
||||||
behat_util::reset_dataroot();
|
behat_util::reset_dataroot();
|
||||||
|
|
|
@ -41,38 +41,122 @@ class core_session_manager_testcase extends advanced_testcase {
|
||||||
$this->assertDebuggingCalled('Session was already started!', DEBUG_DEVELOPER);
|
$this->assertDebuggingCalled('Session was already started!', DEBUG_DEVELOPER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_init_empty_session() {
|
||||||
|
global $SESSION, $USER;
|
||||||
|
$this->resetAfterTest();
|
||||||
|
|
||||||
|
$user = $this->getDataGenerator()->create_user();
|
||||||
|
|
||||||
|
$SESSION->test = true;
|
||||||
|
$this->assertTrue($GLOBALS['SESSION']->test);
|
||||||
|
$this->assertTrue($_SESSION['SESSION']->test);
|
||||||
|
|
||||||
|
\core\session\manager::set_user($user);
|
||||||
|
$this->assertSame($user, $USER);
|
||||||
|
$this->assertSame($user, $GLOBALS['USER']);
|
||||||
|
$this->assertSame($user, $_SESSION['USER']);
|
||||||
|
|
||||||
|
\core\session\manager::init_empty_session();
|
||||||
|
|
||||||
|
$this->assertInstanceOf('stdClass', $SESSION);
|
||||||
|
$this->assertEmpty((array)$SESSION);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
|
||||||
|
$this->assertInstanceOf('stdClass', $USER);
|
||||||
|
$this->assertEquals(array('id' => 0, 'mnethostid' => 1), (array)$USER, '', 0, 10, true);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
// Now test how references work.
|
||||||
|
|
||||||
|
$GLOBALS['SESSION'] = new \stdClass();
|
||||||
|
$GLOBALS['SESSION']->test = true;
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
|
||||||
|
$SESSION = new \stdClass();
|
||||||
|
$SESSION->test2 = true;
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
|
||||||
|
$_SESSION['SESSION'] = new stdClass();
|
||||||
|
$_SESSION['SESSION']->test3 = true;
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
|
||||||
|
$GLOBALS['USER'] = new \stdClass();
|
||||||
|
$GLOBALS['USER']->test = true;
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
$USER = new \stdClass();
|
||||||
|
$USER->test2 = true;
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
$_SESSION['USER'] = new stdClass();
|
||||||
|
$_SESSION['USER']->test3 = true;
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_set_user() {
|
public function test_set_user() {
|
||||||
global $USER;
|
global $USER;
|
||||||
$this->resetAfterTest();
|
$this->resetAfterTest();
|
||||||
|
|
||||||
$user = $this->getDataGenerator()->create_user();
|
|
||||||
$this->setUser(0);
|
|
||||||
$this->assertEquals(0, $USER->id);
|
$this->assertEquals(0, $USER->id);
|
||||||
|
|
||||||
|
$user = $this->getDataGenerator()->create_user();
|
||||||
|
$this->assertObjectHasAttribute('description', $user);
|
||||||
|
$this->assertObjectHasAttribute('password', $user);
|
||||||
|
|
||||||
\core\session\manager::set_user($user);
|
\core\session\manager::set_user($user);
|
||||||
|
|
||||||
$this->assertEquals($user->id, $USER->id);
|
$this->assertEquals($user->id, $USER->id);
|
||||||
|
$this->assertObjectNotHasAttribute('description', $user);
|
||||||
|
$this->assertObjectNotHasAttribute('password', $user);
|
||||||
|
$this->assertObjectHasAttribute('sesskey', $user);
|
||||||
|
$this->assertSame($user, $GLOBALS['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_login_user() {
|
public function test_login_user() {
|
||||||
global $USER;
|
global $USER;
|
||||||
$this->resetAfterTest();
|
$this->resetAfterTest();
|
||||||
|
|
||||||
$user = $this->getDataGenerator()->create_user();
|
|
||||||
$this->setUser(0);
|
|
||||||
$this->assertEquals(0, $USER->id);
|
$this->assertEquals(0, $USER->id);
|
||||||
|
|
||||||
|
$user = $this->getDataGenerator()->create_user();
|
||||||
|
|
||||||
@\core\session\manager::login_user($user); // Ignore header error messages.
|
@\core\session\manager::login_user($user); // Ignore header error messages.
|
||||||
$this->assertEquals($user->id, $USER->id);
|
$this->assertEquals($user->id, $USER->id);
|
||||||
|
|
||||||
|
$this->assertObjectNotHasAttribute('description', $user);
|
||||||
|
$this->assertObjectNotHasAttribute('password', $user);
|
||||||
|
$this->assertSame($user, $GLOBALS['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_terminate_current() {
|
public function test_terminate_current() {
|
||||||
global $USER;
|
global $USER, $SESSION;
|
||||||
$this->resetAfterTest();
|
$this->resetAfterTest();
|
||||||
|
|
||||||
// This can not be tested much without real session...
|
|
||||||
$this->setAdminUser();
|
$this->setAdminUser();
|
||||||
\core\session\manager::terminate_current();
|
\core\session\manager::terminate_current();
|
||||||
$this->assertEquals(0, $USER->id);
|
$this->assertEquals(0, $USER->id);
|
||||||
|
|
||||||
|
$this->assertInstanceOf('stdClass', $SESSION);
|
||||||
|
$this->assertEmpty((array)$SESSION);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
|
||||||
|
$this->assertInstanceOf('stdClass', $USER);
|
||||||
|
$this->assertEquals(array('id' => 0, 'mnethostid' => 1), (array)$USER, '', 0, 10, true);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_write_close() {
|
public function test_write_close() {
|
||||||
|
@ -84,6 +168,9 @@ class core_session_manager_testcase extends advanced_testcase {
|
||||||
$userid = $USER->id;
|
$userid = $USER->id;
|
||||||
\core\session\manager::write_close();
|
\core\session\manager::write_close();
|
||||||
$this->assertSame($userid, $USER->id);
|
$this->assertSame($userid, $USER->id);
|
||||||
|
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_session_exists() {
|
public function test_session_exists() {
|
||||||
|
@ -293,22 +380,37 @@ class core_session_manager_testcase extends advanced_testcase {
|
||||||
* @copyright 2103 Rajesh Taneja <rajesh@moodle.com>
|
* @copyright 2103 Rajesh Taneja <rajesh@moodle.com>
|
||||||
*/
|
*/
|
||||||
public function test_loginas() {
|
public function test_loginas() {
|
||||||
global $USER;
|
global $USER, $SESSION;
|
||||||
$this->resetAfterTest();
|
$this->resetAfterTest();
|
||||||
|
|
||||||
// Set current user as Admin user and save it for later use.
|
// Set current user as Admin user and save it for later use.
|
||||||
$this->setAdminUser();
|
$this->setAdminUser();
|
||||||
$adminuser = $USER;
|
$adminuser = $USER;
|
||||||
|
$adminsession = $SESSION;
|
||||||
// Create a new user and try admin loginas this user.
|
|
||||||
$user = $this->getDataGenerator()->create_user();
|
$user = $this->getDataGenerator()->create_user();
|
||||||
|
$_SESSION['extra'] = true;
|
||||||
|
|
||||||
|
// Try admin loginas this user in system context.
|
||||||
|
$this->assertObjectNotHasAttribute('realuser', $USER);
|
||||||
\core\session\manager::loginas($user->id, context_system::instance());
|
\core\session\manager::loginas($user->id, context_system::instance());
|
||||||
|
|
||||||
$this->assertSame($user->id, $USER->id);
|
$this->assertSame($user->id, $USER->id);
|
||||||
$this->assertSame(context_system::instance(), $USER->loginascontext);
|
$this->assertSame(context_system::instance(), $USER->loginascontext);
|
||||||
$this->assertSame($adminuser->id, $USER->realuser);
|
$this->assertSame($adminuser->id, $USER->realuser);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
$this->assertNotSame($adminuser, $_SESSION['REALUSER']);
|
||||||
|
$this->assertEquals($adminuser, $_SESSION['REALUSER']);
|
||||||
|
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertNotSame($adminsession, $_SESSION['REALSESSION']);
|
||||||
|
$this->assertEquals($adminsession, $_SESSION['REALSESSION']);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('extra', $_SESSION);
|
||||||
|
|
||||||
// Set user as current user and login as admin user in course context.
|
// Set user as current user and login as admin user in course context.
|
||||||
|
\core\session\manager::init_empty_session();
|
||||||
$this->setUser($user);
|
$this->setUser($user);
|
||||||
$this->assertNotEquals($adminuser->id, $USER->id);
|
$this->assertNotEquals($adminuser->id, $USER->id);
|
||||||
$course = $this->getDataGenerator()->create_course();
|
$course = $this->getDataGenerator()->create_course();
|
||||||
|
@ -358,6 +460,9 @@ class core_session_manager_testcase extends advanced_testcase {
|
||||||
$user2 = $this->getDataGenerator()->create_user();
|
$user2 = $this->getDataGenerator()->create_user();
|
||||||
|
|
||||||
$this->setUser($user1);
|
$this->setUser($user1);
|
||||||
|
$normal = \core\session\manager::get_realuser();
|
||||||
|
$this->assertSame($GLOBALS['USER'], $normal);
|
||||||
|
|
||||||
\core\session\manager::loginas($user2->id, context_system::instance());
|
\core\session\manager::loginas($user2->id, context_system::instance());
|
||||||
|
|
||||||
$real = \core\session\manager::get_realuser();
|
$real = \core\session\manager::get_realuser();
|
||||||
|
@ -370,5 +475,6 @@ class core_session_manager_testcase extends advanced_testcase {
|
||||||
unset($user1->sesskey);
|
unset($user1->sesskey);
|
||||||
|
|
||||||
$this->assertEquals($real, $user1);
|
$this->assertEquals($real, $user1);
|
||||||
|
$this->assertSame($_SESSION['REALUSER'], $real);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
229
lib/tests/sessionlib_test.php
Normal file
229
lib/tests/sessionlib_test.php
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
<?php
|
||||||
|
// This file is part of Moodle - http://moodle.org/
|
||||||
|
//
|
||||||
|
// Moodle is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// Moodle is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for sessionlib.php file.
|
||||||
|
*
|
||||||
|
* @package core
|
||||||
|
* @category phpunit
|
||||||
|
* @author Petr Skoda <petr.skoda@totaralms.com>
|
||||||
|
* @copyright 2014 Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
|
||||||
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('MOODLE_INTERNAL') || die();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for sessionlib.php file.
|
||||||
|
*
|
||||||
|
* @package core
|
||||||
|
* @category phpunit
|
||||||
|
* @author Petr Skoda <petr.skoda@totaralms.com>
|
||||||
|
* @copyright 2014 Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
|
||||||
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||||
|
*/
|
||||||
|
class core_sessionlib_testcase extends advanced_testcase {
|
||||||
|
public function test_cron_setup_user() {
|
||||||
|
global $PAGE, $USER, $SESSION, $SITE, $CFG;
|
||||||
|
$this->resetAfterTest();
|
||||||
|
|
||||||
|
// NOTE: this function contains some static caches, let's reset first.
|
||||||
|
cron_setup_user('reset');
|
||||||
|
|
||||||
|
$admin = get_admin();
|
||||||
|
$user1 = $this->getDataGenerator()->create_user();
|
||||||
|
$user2 = $this->getDataGenerator()->create_user();
|
||||||
|
$course = $this->getDataGenerator()->create_course();
|
||||||
|
|
||||||
|
cron_setup_user();
|
||||||
|
$this->assertSame($admin->id, $USER->id);
|
||||||
|
$this->assertSame($PAGE->context, context_course::instance($SITE->id));
|
||||||
|
$this->assertSame($CFG->timezone, $USER->timezone);
|
||||||
|
$this->assertSame('', $USER->lang);
|
||||||
|
$this->assertSame('', $USER->theme);
|
||||||
|
$SESSION->test1 = true;
|
||||||
|
$adminsession = $SESSION;
|
||||||
|
$adminuser = $USER;
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user(null, $course);
|
||||||
|
$this->assertSame($admin->id, $USER->id);
|
||||||
|
$this->assertSame($PAGE->context, context_course::instance($course->id));
|
||||||
|
$this->assertSame($adminsession, $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user($user1);
|
||||||
|
$this->assertSame($user1->id, $USER->id);
|
||||||
|
$this->assertSame($PAGE->context, context_course::instance($SITE->id));
|
||||||
|
$this->assertNotSame($adminsession, $SESSION);
|
||||||
|
$this->assertObjectNotHasAttribute('test1', $SESSION);
|
||||||
|
$this->assertEmpty((array)$SESSION);
|
||||||
|
$usersession1 = $SESSION;
|
||||||
|
$SESSION->test2 = true;
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user($user1);
|
||||||
|
$this->assertSame($user1->id, $USER->id);
|
||||||
|
$this->assertSame($PAGE->context, context_course::instance($SITE->id));
|
||||||
|
$this->assertNotSame($adminsession, $SESSION);
|
||||||
|
$this->assertSame($usersession1, $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user($user2);
|
||||||
|
$this->assertSame($user2->id, $USER->id);
|
||||||
|
$this->assertSame($PAGE->context, context_course::instance($SITE->id));
|
||||||
|
$this->assertNotSame($adminsession, $SESSION);
|
||||||
|
$this->assertNotSame($usersession1, $SESSION);
|
||||||
|
$this->assertEmpty((array)$SESSION);
|
||||||
|
$usersession2 = $SESSION;
|
||||||
|
$usersession2->test3 = true;
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user($user2, $course);
|
||||||
|
$this->assertSame($user2->id, $USER->id);
|
||||||
|
$this->assertSame($PAGE->context, context_course::instance($course->id));
|
||||||
|
$this->assertNotSame($adminsession, $SESSION);
|
||||||
|
$this->assertNotSame($usersession1, $SESSION);
|
||||||
|
$this->assertSame($usersession2, $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user($user1);
|
||||||
|
$this->assertSame($user1->id, $USER->id);
|
||||||
|
$this->assertSame($PAGE->context, context_course::instance($SITE->id));
|
||||||
|
$this->assertNotSame($adminsession, $SESSION);
|
||||||
|
$this->assertNotSame($usersession1, $SESSION);
|
||||||
|
$this->assertEmpty((array)$SESSION);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user();
|
||||||
|
$this->assertSame($admin->id, $USER->id);
|
||||||
|
$this->assertSame($PAGE->context, context_course::instance($SITE->id));
|
||||||
|
$this->assertSame($adminsession, $SESSION);
|
||||||
|
$this->assertSame($adminuser, $USER);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user('reset');
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
cron_setup_user();
|
||||||
|
$this->assertNotSame($adminsession, $SESSION);
|
||||||
|
$this->assertNotSame($adminuser, $USER);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $_SESSION['SESSION']);
|
||||||
|
$this->assertSame($GLOBALS['SESSION'], $SESSION);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sesskey() {
|
||||||
|
global $USER;
|
||||||
|
$this->resetAfterTest();
|
||||||
|
|
||||||
|
$user = $this->getDataGenerator()->create_user();
|
||||||
|
|
||||||
|
\core\session\manager::init_empty_session();
|
||||||
|
$this->assertObjectNotHasAttribute('sesskey', $USER);
|
||||||
|
|
||||||
|
$sesskey = sesskey();
|
||||||
|
$this->assertNotEmpty($sesskey);
|
||||||
|
$this->assertSame($sesskey, $USER->sesskey);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $_SESSION['USER']);
|
||||||
|
$this->assertSame($GLOBALS['USER'], $USER);
|
||||||
|
|
||||||
|
$this->assertSame($sesskey, sesskey());
|
||||||
|
|
||||||
|
// Test incomplete session init - the sesskeys should return random values.
|
||||||
|
$_SESSION = array();
|
||||||
|
unset($GLOBALS['USER']);
|
||||||
|
unset($GLOBALS['SESSION']);
|
||||||
|
|
||||||
|
$this->assertFalse(sesskey());
|
||||||
|
$this->assertArrayNotHasKey('USER', $GLOBALS);
|
||||||
|
$this->assertFalse(sesskey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_confirm_sesskey() {
|
||||||
|
$this->resetAfterTest();
|
||||||
|
|
||||||
|
$sesskey = sesskey();
|
||||||
|
|
||||||
|
try {
|
||||||
|
confirm_sesskey();
|
||||||
|
$this->fail('Exception expected when sesskey not present');
|
||||||
|
} catch (moodle_exception $e) {
|
||||||
|
$this->assertSame('missingparam', $e->errorcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertTrue(confirm_sesskey($sesskey));
|
||||||
|
$this->assertFalse(confirm_sesskey('blahblah'));
|
||||||
|
|
||||||
|
$_GET['sesskey'] = $sesskey;
|
||||||
|
$this->assertTrue(confirm_sesskey());
|
||||||
|
|
||||||
|
$_GET['sesskey'] = 'blah';
|
||||||
|
$this->assertFalse(confirm_sesskey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_require_sesskey() {
|
||||||
|
$this->resetAfterTest();
|
||||||
|
|
||||||
|
$sesskey = sesskey();
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_sesskey();
|
||||||
|
$this->fail('Exception expected when sesskey not present');
|
||||||
|
} catch (moodle_exception $e) {
|
||||||
|
$this->assertSame('missingparam', $e->errorcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_GET['sesskey'] = $sesskey;
|
||||||
|
require_sesskey();
|
||||||
|
|
||||||
|
$_GET['sesskey'] = 'blah';
|
||||||
|
try {
|
||||||
|
require_sesskey();
|
||||||
|
$this->fail('Exception expected when sesskey not incorrect');
|
||||||
|
} catch (moodle_exception $e) {
|
||||||
|
$this->assertSame('invalidsesskey', $e->errorcode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue