Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

   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  
  44      public function setUp(): void {
  45          global $CFG;
  46  
  47          if (!extension_loaded('redis')) {
  48              $this->markTestSkipped('Redis extension not loaded.');
  49          }
  50          if (!defined('TEST_SESSION_REDIS_HOST')) {
  51              $this->markTestSkipped('Session test server not set. define: TEST_SESSION_REDIS_HOST');
  52          }
  53          $version = phpversion('Redis');
  54          if (!$version) {
  55              $this->markTestSkipped('Redis extension version missing');
  56          } else if (version_compare($version, '2.0') <= 0) {
  57              $this->markTestSkipped('Redis extension version must be at least 2.0: now running "' . $version . '"');
  58          }
  59  
  60          $this->resetAfterTest();
  61  
  62          $this->keyprefix = 'phpunit'.rand(1, 100000);
  63  
  64          $CFG->session_redis_host = TEST_SESSION_REDIS_HOST;
  65          $CFG->session_redis_prefix = $this->keyprefix;
  66  
  67          // Set a very short lock timeout to ensure tests run quickly.  We are running single threaded,
  68          // so unless we lock and expect it to be there, we will always see a lock.
  69          $CFG->session_redis_acquire_lock_timeout = 1;
  70          $CFG->session_redis_lock_expire = 70;
  71  
  72          $this->redis = new Redis();
  73          $this->redis->connect(TEST_SESSION_REDIS_HOST);
  74      }
  75  
  76      public function tearDown(): void {
  77          if (!extension_loaded('redis') || !defined('TEST_SESSION_REDIS_HOST')) {
  78              return;
  79          }
  80  
  81          $list = $this->redis->keys($this->keyprefix.'*');
  82          foreach ($list as $keyname) {
  83              $this->redis->del($keyname);
  84          }
  85          $this->redis->close();
  86      }
  87  
  88      public function test_normal_session_read_only() {
  89          $sess = new \core\session\redis();
  90          $sess->set_requires_write_lock(false);
  91          $sess->init();
  92          $this->assertSame('', $sess->handler_read('sess1'));
  93          $this->assertTrue($sess->handler_close());
  94      }
  95  
  96      public function test_normal_session_start_stop_works() {
  97          $sess = new \core\session\redis();
  98          $sess->init();
  99          $sess->set_requires_write_lock(true);
 100          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 101          $this->assertSame('', $sess->handler_read('sess1'));
 102          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 103          $this->assertTrue($sess->handler_close());
 104  
 105          // Read the session again to ensure locking did what it should.
 106          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 107          $this->assertSame('DATA', $sess->handler_read('sess1'));
 108          $this->assertTrue($sess->handler_write('sess1', 'DATA-new'));
 109          $this->assertTrue($sess->handler_close());
 110          $this->assertSessionNoLocks();
 111      }
 112  
 113      public function test_compression_read_and_write_works() {
 114          global $CFG;
 115  
 116          $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_GZIP;
 117  
 118          $sess = new \core\session\redis();
 119          $sess->init();
 120          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 121          $this->assertSame('DATA', $sess->handler_read('sess1'));
 122          $this->assertTrue($sess->handler_close());
 123  
 124          if (extension_loaded('zstd')) {
 125              $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_ZSTD;
 126  
 127              $sess = new \core\session\redis();
 128              $sess->init();
 129              $this->assertTrue($sess->handler_write('sess2', 'DATA'));
 130              $this->assertSame('DATA', $sess->handler_read('sess2'));
 131              $this->assertTrue($sess->handler_close());
 132          }
 133  
 134          $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_NONE;
 135      }
 136  
 137      public function test_session_blocks_with_existing_session() {
 138          $sess = new \core\session\redis();
 139          $sess->init();
 140          $sess->set_requires_write_lock(true);
 141          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 142          $this->assertSame('', $sess->handler_read('sess1'));
 143          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 144          $this->assertTrue($sess->handler_close());
 145  
 146          // Sessions are not locked until they have been saved once.
 147          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 148          $this->assertSame('DATA', $sess->handler_read('sess1'));
 149  
 150          $sessblocked = new \core\session\redis();
 151          $sessblocked->init();
 152          $sessblocked->set_requires_write_lock(true);
 153          $this->assertTrue($sessblocked->handler_open('Not used', 'Not used'));
 154  
 155          // Trap the error log and send it to stdOut so we can expect output at the right times.
 156          $errorlog = tempnam(sys_get_temp_dir(), "rediserrorlog");
 157          $this->iniSet('error_log', $errorlog);
 158          try {
 159              $sessblocked->handler_read('sess1');
 160              $this->fail('Session lock must fail to be obtained.');
 161          } catch (\core\session\exception $e) {
 162              $this->assertStringContainsString("Unable to obtain session lock", $e->getMessage());
 163              $this->assertStringContainsString('Cannot obtain session lock for sid: sess1', file_get_contents($errorlog));
 164          }
 165  
 166          $this->assertTrue($sessblocked->handler_close());
 167          $this->assertTrue($sess->handler_write('sess1', 'DATA-new'));
 168          $this->assertTrue($sess->handler_close());
 169          $this->assertSessionNoLocks();
 170      }
 171  
 172      public function test_session_is_destroyed_when_it_does_not_exist() {
 173          $sess = new \core\session\redis();
 174          $sess->init();
 175          $sess->set_requires_write_lock(true);
 176          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 177          $this->assertTrue($sess->handler_destroy('sess-destroy'));
 178          $this->assertSessionNoLocks();
 179      }
 180  
 181      public function test_session_is_destroyed_when_we_have_it_open() {
 182          $sess = new \core\session\redis();
 183          $sess->init();
 184          $sess->set_requires_write_lock(true);
 185          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 186          $this->assertSame('', $sess->handler_read('sess-destroy'));
 187          $this->assertTrue($sess->handler_destroy('sess-destroy'));
 188          $this->assertTrue($sess->handler_close());
 189          $this->assertSessionNoLocks();
 190      }
 191  
 192      public function test_multiple_sessions_do_not_interfere_with_each_other() {
 193          $sess1 = new \core\session\redis();
 194          $sess1->set_requires_write_lock(true);
 195          $sess1->init();
 196          $sess2 = new \core\session\redis();
 197          $sess2->set_requires_write_lock(true);
 198          $sess2->init();
 199  
 200          // Initialize session 1.
 201          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 202          $this->assertSame('', $sess1->handler_read('sess1'));
 203          $this->assertTrue($sess1->handler_write('sess1', 'DATA'));
 204          $this->assertTrue($sess1->handler_close());
 205  
 206          // Initialize session 2.
 207          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 208          $this->assertSame('', $sess2->handler_read('sess2'));
 209          $this->assertTrue($sess2->handler_write('sess2', 'DATA2'));
 210          $this->assertTrue($sess2->handler_close());
 211  
 212          // Open and read session 1 and 2.
 213          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 214          $this->assertSame('DATA', $sess1->handler_read('sess1'));
 215          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 216          $this->assertSame('DATA2', $sess2->handler_read('sess2'));
 217  
 218          // Write both sessions.
 219          $this->assertTrue($sess1->handler_write('sess1', 'DATAX'));
 220          $this->assertTrue($sess2->handler_write('sess2', 'DATA2X'));
 221  
 222          // Read both sessions.
 223          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 224          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 225          $this->assertEquals('DATAX', $sess1->handler_read('sess1'));
 226          $this->assertEquals('DATA2X', $sess2->handler_read('sess2'));
 227  
 228          // Close both sessions
 229          $this->assertTrue($sess1->handler_close());
 230          $this->assertTrue($sess2->handler_close());
 231  
 232          // Read the session again to ensure locking did what it should.
 233          $this->assertSessionNoLocks();
 234      }
 235  
 236      public function test_multiple_sessions_work_with_a_single_instance() {
 237          $sess = new \core\session\redis();
 238          $sess->init();
 239          $sess->set_requires_write_lock(true);
 240  
 241          // Initialize session 1.
 242          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 243          $this->assertSame('', $sess->handler_read('sess1'));
 244          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 245          $this->assertSame('', $sess->handler_read('sess2'));
 246          $this->assertTrue($sess->handler_write('sess2', 'DATA2'));
 247          $this->assertSame('DATA', $sess->handler_read('sess1'));
 248          $this->assertSame('DATA2', $sess->handler_read('sess2'));
 249          $this->assertTrue($sess->handler_destroy('sess2'));
 250  
 251          $this->assertTrue($sess->handler_close());
 252          $this->assertSessionNoLocks();
 253  
 254          $this->assertTrue($sess->handler_close());
 255      }
 256  
 257      public function test_session_exists_returns_valid_values() {
 258          $sess = new \core\session\redis();
 259          $sess->init();
 260          $sess->set_requires_write_lock(true);
 261  
 262          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 263          $this->assertSame('', $sess->handler_read('sess1'));
 264  
 265          $this->assertFalse($sess->session_exists('sess1'), 'Session must not exist yet, it has not been saved');
 266          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 267          $this->assertTrue($sess->session_exists('sess1'), 'Session must exist now.');
 268          $this->assertTrue($sess->handler_destroy('sess1'));
 269          $this->assertFalse($sess->session_exists('sess1'), 'Session should be destroyed.');
 270      }
 271  
 272      public function test_kill_sessions_removes_the_session_from_redis() {
 273          global $DB;
 274  
 275          $sess = new \core\session\redis();
 276          $sess->init();
 277  
 278          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 279          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 280          $this->assertTrue($sess->handler_write('sess2', 'DATA'));
 281          $this->assertTrue($sess->handler_write('sess3', 'DATA'));
 282  
 283          $sessiondata = new \stdClass();
 284          $sessiondata->userid = 2;
 285          $sessiondata->timecreated = time();
 286          $sessiondata->timemodified = time();
 287  
 288          $sessiondata->sid = 'sess1';
 289          $DB->insert_record('sessions', $sessiondata);
 290          $sessiondata->sid = 'sess2';
 291          $DB->insert_record('sessions', $sessiondata);
 292          $sessiondata->sid = 'sess3';
 293          $DB->insert_record('sessions', $sessiondata);
 294  
 295          $this->assertNotEquals('', $sess->handler_read('sess1'));
 296          $sess->kill_session('sess1');
 297          $this->assertEquals('', $sess->handler_read('sess1'));
 298  
 299          $this->assertEmpty($this->redis->keys($this->keyprefix.'sess1.lock'));
 300  
 301          $sess->kill_all_sessions();
 302  
 303          $this->assertEquals(3, $DB->count_records('sessions'), 'Moodle handles session database, plugin must not change it.');
 304          $this->assertSessionNoLocks();
 305          $this->assertEmpty($this->redis->keys($this->keyprefix.'*'), 'There should be no session data left.');
 306      }
 307  
 308      public function test_exception_when_connection_attempts_exceeded() {
 309          global $CFG;
 310  
 311          $CFG->session_redis_port = 111111;
 312          $actual = '';
 313  
 314          $sess = new \core\session\redis();
 315          try {
 316              $sess->init();
 317          } catch (RedisException $e) {
 318              $actual = $e->getMessage();
 319          }
 320  
 321          $expected = 'Failed to connect (try 5 out of 5) to redis at ' . TEST_SESSION_REDIS_HOST . ':111111';
 322          $this->assertDebuggingCalledCount(5);
 323          $this->assertStringContainsString($expected, $actual);
 324      }
 325  
 326      /**
 327       * Assert that we don't have any session locks in Redis.
 328       */
 329      protected function assertSessionNoLocks() {
 330          $this->assertEmpty($this->redis->keys($this->keyprefix.'*.lock'));
 331      }
 332  }