Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace core;
  18  
  19  use Redis;
  20  use RedisException;
  21  
  22  /**
  23   * Unit tests for classes/session/redis.php.
  24   *
  25   * NOTE: in order to execute this test you need to set up
  26   *       Redis server and add configuration a constant
  27   *       to config.php or phpunit.xml configuration file:
  28   *
  29   * define('TEST_SESSION_REDIS_HOST', '127.0.0.1');
  30   *
  31   * @package   core
  32   * @author    Russell Smith <mr-russ@smith2001.net>
  33   * @copyright 2016 Russell Smith
  34   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  35   * @runClassInSeparateProcess
  36   */
  37  class session_redis_test extends \advanced_testcase {
  38  
  39      /** @var $keyprefix This key prefix used when testing Redis */
  40      protected $keyprefix = null;
  41      /** @var $redis The current testing redis connection */
  42      protected $redis = null;
  43      /** @var int $acquiretimeout how long we wait for session lock in seconds when testing Redis */
  44      protected $acquiretimeout = 1;
  45      /** @var int $lockexpire how long to wait in seconds before expiring the lock when testing Redis */
  46      protected $lockexpire = 70;
  47  
  48  
  49      public function setUp(): void {
  50          global $CFG;
  51  
  52          if (!extension_loaded('redis')) {
  53              $this->markTestSkipped('Redis extension not loaded.');
  54          }
  55          if (!defined('TEST_SESSION_REDIS_HOST')) {
  56              $this->markTestSkipped('Session test server not set. define: TEST_SESSION_REDIS_HOST');
  57          }
  58          $version = phpversion('Redis');
  59          if (!$version) {
  60              $this->markTestSkipped('Redis extension version missing');
  61          } else if (version_compare($version, '2.0') <= 0) {
  62              $this->markTestSkipped('Redis extension version must be at least 2.0: now running "' . $version . '"');
  63          }
  64  
  65          $this->resetAfterTest();
  66  
  67          $this->keyprefix = 'phpunit'.rand(1, 100000);
  68  
  69          $CFG->session_redis_host = TEST_SESSION_REDIS_HOST;
  70          $CFG->session_redis_prefix = $this->keyprefix;
  71  
  72          // Set a very short lock timeout to ensure tests run quickly.  We are running single threaded,
  73          // so unless we lock and expect it to be there, we will always see a lock.
  74          $CFG->session_redis_acquire_lock_timeout = $this->acquiretimeout;
  75          $CFG->session_redis_lock_expire = $this->lockexpire;
  76  
  77          $this->redis = new Redis();
  78          $this->redis->connect(TEST_SESSION_REDIS_HOST);
  79      }
  80  
  81      public function tearDown(): void {
  82          if (!extension_loaded('redis') || !defined('TEST_SESSION_REDIS_HOST')) {
  83              return;
  84          }
  85  
  86          $list = $this->redis->keys($this->keyprefix.'*');
  87          foreach ($list as $keyname) {
  88              $this->redis->del($keyname);
  89          }
  90          $this->redis->close();
  91      }
  92  
  93      public function test_normal_session_read_only() {
  94          $sess = new \core\session\redis();
  95          $sess->set_requires_write_lock(false);
  96          $sess->init();
  97          $this->assertSame('', $sess->handler_read('sess1'));
  98          $this->assertTrue($sess->handler_close());
  99      }
 100  
 101      public function test_normal_session_start_stop_works() {
 102          $sess = new \core\session\redis();
 103          $sess->init();
 104          $sess->set_requires_write_lock(true);
 105          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 106          $this->assertSame('', $sess->handler_read('sess1'));
 107          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 108          $this->assertTrue($sess->handler_close());
 109  
 110          // Read the session again to ensure locking did what it should.
 111          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 112          $this->assertSame('DATA', $sess->handler_read('sess1'));
 113          $this->assertTrue($sess->handler_write('sess1', 'DATA-new'));
 114          $this->assertTrue($sess->handler_close());
 115          $this->assertSessionNoLocks();
 116      }
 117  
 118      public function test_compression_read_and_write_works() {
 119          global $CFG;
 120  
 121          $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_GZIP;
 122  
 123          $sess = new \core\session\redis();
 124          $sess->init();
 125          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 126          $this->assertSame('DATA', $sess->handler_read('sess1'));
 127          $this->assertTrue($sess->handler_close());
 128  
 129          if (extension_loaded('zstd')) {
 130              $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_ZSTD;
 131  
 132              $sess = new \core\session\redis();
 133              $sess->init();
 134              $this->assertTrue($sess->handler_write('sess2', 'DATA'));
 135              $this->assertSame('DATA', $sess->handler_read('sess2'));
 136              $this->assertTrue($sess->handler_close());
 137          }
 138  
 139          $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_NONE;
 140      }
 141  
 142      public function test_session_blocks_with_existing_session() {
 143          $sess = new \core\session\redis();
 144          $sess->init();
 145          $sess->set_requires_write_lock(true);
 146          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 147          $this->assertSame('', $sess->handler_read('sess1'));
 148          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 149          $this->assertTrue($sess->handler_close());
 150  
 151          // Sessions are not locked until they have been saved once.
 152          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 153          $this->assertSame('DATA', $sess->handler_read('sess1'));
 154  
 155          $sessblocked = new \core\session\redis();
 156          $sessblocked->init();
 157          $sessblocked->set_requires_write_lock(true);
 158          $this->assertTrue($sessblocked->handler_open('Not used', 'Not used'));
 159  
 160          // Trap the error log and send it to stdOut so we can expect output at the right times.
 161          $errorlog = tempnam(sys_get_temp_dir(), "rediserrorlog");
 162          $this->iniSet('error_log', $errorlog);
 163          try {
 164              $sessblocked->handler_read('sess1');
 165              $this->fail('Session lock must fail to be obtained.');
 166          } catch (\core\session\exception $e) {
 167              $this->assertStringContainsString("Unable to obtain lock for session id sess1", $e->getMessage());
 168              $this->assertStringContainsString('within 1 sec.', $e->getMessage());
 169              $this->assertStringContainsString('session lock timeout (1 min 10 secs) ', $e->getMessage());
 170              $this->assertStringContainsString('Cannot obtain session lock for sid: sess1', file_get_contents($errorlog));
 171          }
 172  
 173          $this->assertTrue($sessblocked->handler_close());
 174          $this->assertTrue($sess->handler_write('sess1', 'DATA-new'));
 175          $this->assertTrue($sess->handler_close());
 176          $this->assertSessionNoLocks();
 177      }
 178  
 179      public function test_session_is_destroyed_when_it_does_not_exist() {
 180          $sess = new \core\session\redis();
 181          $sess->init();
 182          $sess->set_requires_write_lock(true);
 183          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 184          $this->assertTrue($sess->handler_destroy('sess-destroy'));
 185          $this->assertSessionNoLocks();
 186      }
 187  
 188      public function test_session_is_destroyed_when_we_have_it_open() {
 189          $sess = new \core\session\redis();
 190          $sess->init();
 191          $sess->set_requires_write_lock(true);
 192          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 193          $this->assertSame('', $sess->handler_read('sess-destroy'));
 194          $this->assertTrue($sess->handler_destroy('sess-destroy'));
 195          $this->assertTrue($sess->handler_close());
 196          $this->assertSessionNoLocks();
 197      }
 198  
 199      public function test_multiple_sessions_do_not_interfere_with_each_other() {
 200          $sess1 = new \core\session\redis();
 201          $sess1->set_requires_write_lock(true);
 202          $sess1->init();
 203          $sess2 = new \core\session\redis();
 204          $sess2->set_requires_write_lock(true);
 205          $sess2->init();
 206  
 207          // Initialize session 1.
 208          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 209          $this->assertSame('', $sess1->handler_read('sess1'));
 210          $this->assertTrue($sess1->handler_write('sess1', 'DATA'));
 211          $this->assertTrue($sess1->handler_close());
 212  
 213          // Initialize session 2.
 214          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 215          $this->assertSame('', $sess2->handler_read('sess2'));
 216          $this->assertTrue($sess2->handler_write('sess2', 'DATA2'));
 217          $this->assertTrue($sess2->handler_close());
 218  
 219          // Open and read session 1 and 2.
 220          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 221          $this->assertSame('DATA', $sess1->handler_read('sess1'));
 222          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 223          $this->assertSame('DATA2', $sess2->handler_read('sess2'));
 224  
 225          // Write both sessions.
 226          $this->assertTrue($sess1->handler_write('sess1', 'DATAX'));
 227          $this->assertTrue($sess2->handler_write('sess2', 'DATA2X'));
 228  
 229          // Read both sessions.
 230          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 231          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 232          $this->assertEquals('DATAX', $sess1->handler_read('sess1'));
 233          $this->assertEquals('DATA2X', $sess2->handler_read('sess2'));
 234  
 235          // Close both sessions
 236          $this->assertTrue($sess1->handler_close());
 237          $this->assertTrue($sess2->handler_close());
 238  
 239          // Read the session again to ensure locking did what it should.
 240          $this->assertSessionNoLocks();
 241      }
 242  
 243      public function test_multiple_sessions_work_with_a_single_instance() {
 244          $sess = new \core\session\redis();
 245          $sess->init();
 246          $sess->set_requires_write_lock(true);
 247  
 248          // Initialize session 1.
 249          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 250          $this->assertSame('', $sess->handler_read('sess1'));
 251          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 252          $this->assertSame('', $sess->handler_read('sess2'));
 253          $this->assertTrue($sess->handler_write('sess2', 'DATA2'));
 254          $this->assertSame('DATA', $sess->handler_read('sess1'));
 255          $this->assertSame('DATA2', $sess->handler_read('sess2'));
 256          $this->assertTrue($sess->handler_destroy('sess2'));
 257  
 258          $this->assertTrue($sess->handler_close());
 259          $this->assertSessionNoLocks();
 260  
 261          $this->assertTrue($sess->handler_close());
 262      }
 263  
 264      public function test_session_exists_returns_valid_values() {
 265          $sess = new \core\session\redis();
 266          $sess->init();
 267          $sess->set_requires_write_lock(true);
 268  
 269          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 270          $this->assertSame('', $sess->handler_read('sess1'));
 271  
 272          $this->assertFalse($sess->session_exists('sess1'), 'Session must not exist yet, it has not been saved');
 273          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 274          $this->assertTrue($sess->session_exists('sess1'), 'Session must exist now.');
 275          $this->assertTrue($sess->handler_destroy('sess1'));
 276          $this->assertFalse($sess->session_exists('sess1'), 'Session should be destroyed.');
 277      }
 278  
 279      public function test_kill_sessions_removes_the_session_from_redis() {
 280          global $DB;
 281  
 282          $sess = new \core\session\redis();
 283          $sess->init();
 284  
 285          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 286          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 287          $this->assertTrue($sess->handler_write('sess2', 'DATA'));
 288          $this->assertTrue($sess->handler_write('sess3', 'DATA'));
 289  
 290          $sessiondata = new \stdClass();
 291          $sessiondata->userid = 2;
 292          $sessiondata->timecreated = time();
 293          $sessiondata->timemodified = time();
 294  
 295          $sessiondata->sid = 'sess1';
 296          $DB->insert_record('sessions', $sessiondata);
 297          $sessiondata->sid = 'sess2';
 298          $DB->insert_record('sessions', $sessiondata);
 299          $sessiondata->sid = 'sess3';
 300          $DB->insert_record('sessions', $sessiondata);
 301  
 302          $this->assertNotEquals('', $sess->handler_read('sess1'));
 303          $sess->kill_session('sess1');
 304          $this->assertEquals('', $sess->handler_read('sess1'));
 305  
 306          $this->assertEmpty($this->redis->keys($this->keyprefix.'sess1.lock'));
 307  
 308          $sess->kill_all_sessions();
 309  
 310          $this->assertEquals(3, $DB->count_records('sessions'), 'Moodle handles session database, plugin must not change it.');
 311          $this->assertSessionNoLocks();
 312          $this->assertEmpty($this->redis->keys($this->keyprefix.'*'), 'There should be no session data left.');
 313      }
 314  
 315      public function test_exception_when_connection_attempts_exceeded() {
 316          global $CFG;
 317  
 318          $CFG->session_redis_port = 111111;
 319          $actual = '';
 320  
 321          $sess = new \core\session\redis();
 322          try {
 323              $sess->init();
 324          } catch (RedisException $e) {
 325              $actual = $e->getMessage();
 326          }
 327  
 328          $expected = 'Failed to connect (try 5 out of 5) to redis at ' . TEST_SESSION_REDIS_HOST . ':111111';
 329          $this->assertDebuggingCalledCount(5);
 330          $this->assertStringContainsString($expected, $actual);
 331      }
 332  
 333      /**
 334       * Assert that we don't have any session locks in Redis.
 335       */
 336      protected function assertSessionNoLocks() {
 337          $this->assertEmpty($this->redis->keys($this->keyprefix.'*.lock'));
 338      }
 339  }