Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403] [Versions 402 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   * @covers    \core\session\redis
  33   * @author    Russell Smith <mr-russ@smith2001.net>
  34   * @copyright 2016 Russell Smith
  35   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  36   * @runClassInSeparateProcess
  37   */
  38  class session_redis_test extends \advanced_testcase {
  39  
  40      /** @var $keyprefix This key prefix used when testing Redis */
  41      protected $keyprefix = null;
  42      /** @var $redis The current testing redis connection */
  43      protected $redis = null;
  44      /** @var bool $encrypted Is the current testing redis connection encrypted*/
  45      protected $encrypted = false;
  46      /** @var int $acquiretimeout how long we wait for session lock in seconds when testing Redis */
  47      protected $acquiretimeout = 1;
  48      /** @var int $lockexpire how long to wait in seconds before expiring the lock when testing Redis */
  49      protected $lockexpire = 70;
  50  
  51  
  52      public function setUp(): void {
  53          global $CFG;
  54  
  55          if (!extension_loaded('redis')) {
  56              $this->markTestSkipped('Redis extension not loaded.');
  57          }
  58          if (!defined('TEST_SESSION_REDIS_HOST')) {
  59              $this->markTestSkipped('Session test server not set. define: TEST_SESSION_REDIS_HOST');
  60          }
  61          $version = phpversion('Redis');
  62          if (!$version) {
  63              $this->markTestSkipped('Redis extension version missing');
  64          } else if (version_compare($version, '2.0') <= 0) {
  65              $this->markTestSkipped('Redis extension version must be at least 2.0: now running "' . $version . '"');
  66          }
  67  
  68          $this->resetAfterTest();
  69  
  70          $this->keyprefix = 'phpunit'.rand(1, 100000);
  71  
  72          $CFG->session_redis_host = TEST_SESSION_REDIS_HOST;
  73          if (strpos(TEST_SESSION_REDIS_HOST, ':')) {
  74              list($server, $port) = explode(':', TEST_SESSION_REDIS_HOST);
  75          } else {
  76              $server = TEST_SESSION_REDIS_HOST;
  77              $port = 6379;
  78          }
  79  
  80          $opts = [];
  81          if (defined('TEST_SESSION_REDIS_ENCRYPT') && TEST_SESSION_REDIS_ENCRYPT) {
  82              $this->encrypted = true;
  83              $sslopts = $CFG->session_redis_encrypt = ['verify_peer' => false, 'verify_peer_name' => false];
  84              $opts['stream'] = $sslopts;
  85          }
  86          $CFG->session_redis_prefix = $this->keyprefix;
  87  
  88          // Set a very short lock timeout to ensure tests run quickly.  We are running single threaded,
  89          // so unless we lock and expect it to be there, we will always see a lock.
  90          $CFG->session_redis_acquire_lock_timeout = $this->acquiretimeout;
  91          $CFG->session_redis_lock_expire = $this->lockexpire;
  92  
  93          $this->redis = new Redis();
  94          $this->redis->connect($server, $port, 1, null, 1, 0, $opts);
  95          if (!$this->redis->ping()) {
  96              $this->markTestSkipped("Redis ping failed");
  97          }
  98      }
  99  
 100      public function tearDown(): void {
 101          if (!extension_loaded('redis') || !defined('TEST_SESSION_REDIS_HOST')) {
 102              return;
 103          }
 104  
 105          $list = $this->redis->keys($this->keyprefix.'*');
 106          foreach ($list as $keyname) {
 107              $this->redis->del($keyname);
 108          }
 109          $this->redis->close();
 110      }
 111  
 112      public function test_normal_session_read_only() {
 113          $sess = new \core\session\redis();
 114          $sess->set_requires_write_lock(false);
 115          $sess->init();
 116          $this->assertSame('', $sess->handler_read('sess1'));
 117          $this->assertTrue($sess->handler_close());
 118      }
 119  
 120      public function test_normal_session_start_stop_works() {
 121          $sess = new \core\session\redis();
 122          $sess->init();
 123          $sess->set_requires_write_lock(true);
 124          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 125          $this->assertSame('', $sess->handler_read('sess1'));
 126          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 127          $this->assertTrue($sess->handler_close());
 128  
 129          // Read the session again to ensure locking did what it should.
 130          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 131          $this->assertSame('DATA', $sess->handler_read('sess1'));
 132          $this->assertTrue($sess->handler_write('sess1', 'DATA-new'));
 133          $this->assertTrue($sess->handler_close());
 134          $this->assertSessionNoLocks();
 135      }
 136  
 137      public function test_compression_read_and_write_works() {
 138          global $CFG;
 139  
 140          $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_GZIP;
 141  
 142          $sess = new \core\session\redis();
 143          $sess->init();
 144          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 145          $this->assertSame('DATA', $sess->handler_read('sess1'));
 146          $this->assertTrue($sess->handler_close());
 147  
 148          if (extension_loaded('zstd')) {
 149              $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_ZSTD;
 150  
 151              $sess = new \core\session\redis();
 152              $sess->init();
 153              $this->assertTrue($sess->handler_write('sess2', 'DATA'));
 154              $this->assertSame('DATA', $sess->handler_read('sess2'));
 155              $this->assertTrue($sess->handler_close());
 156          }
 157  
 158          $CFG->session_redis_compressor = \core\session\redis::COMPRESSION_NONE;
 159      }
 160  
 161      public function test_session_blocks_with_existing_session() {
 162          $sess = new \core\session\redis();
 163          $sess->init();
 164          $sess->set_requires_write_lock(true);
 165          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 166          $this->assertSame('', $sess->handler_read('sess1'));
 167          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 168          $this->assertTrue($sess->handler_close());
 169  
 170          // Sessions are not locked until they have been saved once.
 171          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 172          $this->assertSame('DATA', $sess->handler_read('sess1'));
 173  
 174          $sessblocked = new \core\session\redis();
 175          $sessblocked->init();
 176          $sessblocked->set_requires_write_lock(true);
 177          $this->assertTrue($sessblocked->handler_open('Not used', 'Not used'));
 178  
 179          // Trap the error log and send it to stdOut so we can expect output at the right times.
 180          $errorlog = tempnam(sys_get_temp_dir(), "rediserrorlog");
 181          $this->iniSet('error_log', $errorlog);
 182          try {
 183              $sessblocked->handler_read('sess1');
 184              $this->fail('Session lock must fail to be obtained.');
 185          } catch (\core\session\exception $e) {
 186              $this->assertStringContainsString("Unable to obtain lock for session id sess1", $e->getMessage());
 187              $this->assertStringContainsString('within 1 sec.', $e->getMessage());
 188              $this->assertStringContainsString('session lock timeout (1 min 10 secs) ', $e->getMessage());
 189              $this->assertStringContainsString('Cannot obtain session lock for sid: sess1', file_get_contents($errorlog));
 190          }
 191  
 192          $this->assertTrue($sessblocked->handler_close());
 193          $this->assertTrue($sess->handler_write('sess1', 'DATA-new'));
 194          $this->assertTrue($sess->handler_close());
 195          $this->assertSessionNoLocks();
 196      }
 197  
 198      public function test_session_is_destroyed_when_it_does_not_exist() {
 199          $sess = new \core\session\redis();
 200          $sess->init();
 201          $sess->set_requires_write_lock(true);
 202          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 203          $this->assertTrue($sess->handler_destroy('sess-destroy'));
 204          $this->assertSessionNoLocks();
 205      }
 206  
 207      public function test_session_is_destroyed_when_we_have_it_open() {
 208          $sess = new \core\session\redis();
 209          $sess->init();
 210          $sess->set_requires_write_lock(true);
 211          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 212          $this->assertSame('', $sess->handler_read('sess-destroy'));
 213          $this->assertTrue($sess->handler_destroy('sess-destroy'));
 214          $this->assertTrue($sess->handler_close());
 215          $this->assertSessionNoLocks();
 216      }
 217  
 218      public function test_multiple_sessions_do_not_interfere_with_each_other() {
 219          $sess1 = new \core\session\redis();
 220          $sess1->set_requires_write_lock(true);
 221          $sess1->init();
 222          $sess2 = new \core\session\redis();
 223          $sess2->set_requires_write_lock(true);
 224          $sess2->init();
 225  
 226          // Initialize session 1.
 227          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 228          $this->assertSame('', $sess1->handler_read('sess1'));
 229          $this->assertTrue($sess1->handler_write('sess1', 'DATA'));
 230          $this->assertTrue($sess1->handler_close());
 231  
 232          // Initialize session 2.
 233          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 234          $this->assertSame('', $sess2->handler_read('sess2'));
 235          $this->assertTrue($sess2->handler_write('sess2', 'DATA2'));
 236          $this->assertTrue($sess2->handler_close());
 237  
 238          // Open and read session 1 and 2.
 239          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 240          $this->assertSame('DATA', $sess1->handler_read('sess1'));
 241          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 242          $this->assertSame('DATA2', $sess2->handler_read('sess2'));
 243  
 244          // Write both sessions.
 245          $this->assertTrue($sess1->handler_write('sess1', 'DATAX'));
 246          $this->assertTrue($sess2->handler_write('sess2', 'DATA2X'));
 247  
 248          // Read both sessions.
 249          $this->assertTrue($sess1->handler_open('Not used', 'Not used'));
 250          $this->assertTrue($sess2->handler_open('Not used', 'Not used'));
 251          $this->assertEquals('DATAX', $sess1->handler_read('sess1'));
 252          $this->assertEquals('DATA2X', $sess2->handler_read('sess2'));
 253  
 254          // Close both sessions
 255          $this->assertTrue($sess1->handler_close());
 256          $this->assertTrue($sess2->handler_close());
 257  
 258          // Read the session again to ensure locking did what it should.
 259          $this->assertSessionNoLocks();
 260      }
 261  
 262      public function test_multiple_sessions_work_with_a_single_instance() {
 263          $sess = new \core\session\redis();
 264          $sess->init();
 265          $sess->set_requires_write_lock(true);
 266  
 267          // Initialize session 1.
 268          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 269          $this->assertSame('', $sess->handler_read('sess1'));
 270          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 271          $this->assertSame('', $sess->handler_read('sess2'));
 272          $this->assertTrue($sess->handler_write('sess2', 'DATA2'));
 273          $this->assertSame('DATA', $sess->handler_read('sess1'));
 274          $this->assertSame('DATA2', $sess->handler_read('sess2'));
 275          $this->assertTrue($sess->handler_destroy('sess2'));
 276  
 277          $this->assertTrue($sess->handler_close());
 278          $this->assertSessionNoLocks();
 279  
 280          $this->assertTrue($sess->handler_close());
 281      }
 282  
 283      public function test_session_exists_returns_valid_values() {
 284          $sess = new \core\session\redis();
 285          $sess->init();
 286          $sess->set_requires_write_lock(true);
 287  
 288          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 289          $this->assertSame('', $sess->handler_read('sess1'));
 290  
 291          $this->assertFalse($sess->session_exists('sess1'), 'Session must not exist yet, it has not been saved');
 292          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 293          $this->assertTrue($sess->session_exists('sess1'), 'Session must exist now.');
 294          $this->assertTrue($sess->handler_destroy('sess1'));
 295          $this->assertFalse($sess->session_exists('sess1'), 'Session should be destroyed.');
 296      }
 297  
 298      public function test_kill_sessions_removes_the_session_from_redis() {
 299          global $DB;
 300  
 301          $sess = new \core\session\redis();
 302          $sess->init();
 303  
 304          $this->assertTrue($sess->handler_open('Not used', 'Not used'));
 305          $this->assertTrue($sess->handler_write('sess1', 'DATA'));
 306          $this->assertTrue($sess->handler_write('sess2', 'DATA'));
 307          $this->assertTrue($sess->handler_write('sess3', 'DATA'));
 308  
 309          $sessiondata = new \stdClass();
 310          $sessiondata->userid = 2;
 311          $sessiondata->timecreated = time();
 312          $sessiondata->timemodified = time();
 313  
 314          $sessiondata->sid = 'sess1';
 315          $DB->insert_record('sessions', $sessiondata);
 316          $sessiondata->sid = 'sess2';
 317          $DB->insert_record('sessions', $sessiondata);
 318          $sessiondata->sid = 'sess3';
 319          $DB->insert_record('sessions', $sessiondata);
 320  
 321          $this->assertNotEquals('', $sess->handler_read('sess1'));
 322          $sess->kill_session('sess1');
 323          $this->assertEquals('', $sess->handler_read('sess1'));
 324  
 325          $this->assertEmpty($this->redis->keys($this->keyprefix.'sess1.lock'));
 326  
 327          $sess->kill_all_sessions();
 328  
 329          $this->assertEquals(3, $DB->count_records('sessions'), 'Moodle handles session database, plugin must not change it.');
 330          $this->assertSessionNoLocks();
 331          $this->assertEmpty($this->redis->keys($this->keyprefix.'*'), 'There should be no session data left.');
 332      }
 333  
 334      public function test_exception_when_connection_attempts_exceeded() {
 335          global $CFG;
 336  
 337          $CFG->session_redis_port = 111111;
 338          $actual = '';
 339  
 340          $sess = new \core\session\redis();
 341          try {
 342              $sess->init();
 343          } catch (RedisException $e) {
 344              $actual = $e->getMessage();
 345          }
 346  
 347          $host = TEST_SESSION_REDIS_HOST;
 348          if ($this->encrypted) {
 349              $host = "tls://$host";
 350          }
 351          $expected = "Failed to connect (try 5 out of 5) to redis at $host:111111";
 352          $this->assertDebuggingCalledCount(5);
 353          $this->assertStringContainsString($expected, $actual);
 354      }
 355  
 356      /**
 357       * Assert that we don't have any session locks in Redis.
 358       */
 359      protected function assertSessionNoLocks() {
 360          $this->assertEmpty($this->redis->keys($this->keyprefix.'*.lock'));
 361      }
 362  
 363      public function test_session_redis_encrypt() {
 364          global $CFG;
 365  
 366          $CFG->session_redis_encrypt = ['verify_peer' => false, 'verify_peer_name' => false];
 367  
 368          $sess = new \core\session\redis();
 369  
 370          $prop = new \ReflectionProperty(\core\session\redis::class, 'host');
 371          $prop->setAccessible(true);
 372          $this->assertEquals('tls://' . TEST_SESSION_REDIS_HOST, $prop->getValue($sess));
 373      }
 374  }