Search moodle.org's
Developer Documentation

See Release Notes

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

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

   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 file_system_filedir;
  20  
  21  defined('MOODLE_INTERNAL') || die();
  22  
  23  global $CFG;
  24  require_once($CFG->libdir . '/filestorage/file_system.php');
  25  require_once($CFG->libdir . '/filestorage/file_system_filedir.php');
  26  
  27  /**
  28   * Unit tests for file_system_filedir.
  29   *
  30   * @package   core
  31   * @category  test
  32   * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
  33   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  34   * @coversDefaultClass \file_system_filedir
  35   */
  36  class file_system_filedir_test extends \advanced_testcase {
  37  
  38      /**
  39       * Shared test setUp.
  40       */
  41      public function setUp(): void {
  42          // Reset the file storage so that subsequent fetches to get_file_storage are called after
  43          // configuration is prepared.
  44          get_file_storage(true);
  45      }
  46  
  47      /**
  48       * Shared teset tearDown.
  49       */
  50      public function tearDown(): void {
  51          // Reset the file storage so that subsequent tests will use the standard file storage.
  52          get_file_storage(true);
  53      }
  54  
  55      /**
  56       * Helper function to help setup and configure the virtual file system stream.
  57       *
  58       * @param   array $filedir Directory structure and content of the filedir
  59       * @param   array $trashdir Directory structure and content of the sourcedir
  60       * @param   array $sourcedir Directory structure and content of a directory used for source files for tests
  61       * @return  \org\bovigo\vfs\vfsStream
  62       */
  63      protected function setup_vfile_root($filedir = [], $trashdir = [], $sourcedir = null) {
  64          global $CFG;
  65          $this->resetAfterTest();
  66  
  67          $content = [];
  68          if ($filedir !== null) {
  69              $content['filedir'] = $filedir;
  70          }
  71  
  72          if ($trashdir !== null) {
  73              $content['trashdir'] = $trashdir;
  74          }
  75  
  76          if ($sourcedir !== null) {
  77              $content['sourcedir'] = $sourcedir;
  78          }
  79  
  80          $vfileroot = \org\bovigo\vfs\vfsStream::setup('root', null, $content);
  81  
  82          $CFG->filedir = \org\bovigo\vfs\vfsStream::url('root/filedir');
  83          $CFG->trashdir = \org\bovigo\vfs\vfsStream::url('root/trashdir');
  84  
  85          return $vfileroot;
  86      }
  87  
  88      /**
  89       * Helper to create a stored file objectw with the given supplied content.
  90       *
  91       * @param   string  $filecontent The content of the mocked file
  92       * @param   string  $filename The file name to use in the stored_file
  93       * @param   array   $mockedmethods A list of methods you intend to override
  94       *                  If no methods are specified, only abstract functions are mocked.
  95       * @return \stored_file
  96       */
  97      protected function get_stored_file($filecontent, $filename = null, $mockedmethods = []) {
  98          $contenthash = \file_storage::hash_from_string($filecontent);
  99          if (empty($filename)) {
 100              $filename = $contenthash;
 101          }
 102  
 103          $file = $this->getMockBuilder(\stored_file::class)
 104              ->onlyMethods($mockedmethods)
 105              ->setConstructorArgs([
 106                  get_file_storage(),
 107                  (object) [
 108                      'contenthash' => $contenthash,
 109                      'filesize' => strlen($filecontent),
 110                      'filename' => $filename,
 111                  ]
 112              ])
 113              ->getMock();
 114  
 115          return $file;
 116      }
 117  
 118      /**
 119       * Get a testable mock of the file_system_filedir class.
 120       *
 121       * @param   array   $mockedmethods A list of methods you intend to override
 122       *                  If no methods are specified, only abstract functions are mocked.
 123       * @return \file_system
 124       */
 125      protected function get_testable_mock($mockedmethods = []) {
 126          $fs = $this->getMockBuilder(file_system_filedir::class)
 127              ->onlyMethods($mockedmethods)
 128              ->getMock();
 129  
 130          return $fs;
 131      }
 132  
 133      /**
 134       * Ensure that an appropriate error is shown when the filedir directory
 135       * is not writable.
 136       *
 137       * @covers ::__construct
 138       */
 139      public function test_readonly_filesystem_filedir() {
 140          $this->resetAfterTest();
 141  
 142          // Setup the filedir but remove permissions.
 143          $vfileroot = $this->setup_vfile_root(null);
 144  
 145          // Make the target path readonly.
 146          $vfileroot->chmod(0444)
 147              ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
 148  
 149          // This should generate an exception.
 150          $this->expectException('file_exception');
 151          $this->expectExceptionMessageMatches(
 152              '/Cannot create local file pool directories. Please verify permissions in dataroot./');
 153  
 154          new file_system_filedir();
 155      }
 156  
 157      /**
 158       * Ensure that an appropriate error is shown when the trash directory
 159       * is not writable.
 160       *
 161       * @covers ::__construct
 162       */
 163      public function test_readonly_filesystem_trashdir() {
 164          $this->resetAfterTest();
 165  
 166          // Setup the trashdir but remove permissions.
 167          $vfileroot = $this->setup_vfile_root([], null);
 168  
 169          // Make the target path readonly.
 170          $vfileroot->chmod(0444)
 171              ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
 172  
 173          // This should generate an exception.
 174          $this->expectException('file_exception');
 175          $this->expectExceptionMessageMatches(
 176              '/Cannot create local file pool directories. Please verify permissions in dataroot./');
 177  
 178          new file_system_filedir();
 179      }
 180  
 181      /**
 182       * Test that the standard Moodle warning message is put into the filedir.
 183       *
 184       * @covers ::__construct
 185       */
 186      public function test_warnings_put_in_place() {
 187          $this->resetAfterTest();
 188  
 189          $vfileroot = $this->setup_vfile_root(null);
 190  
 191          new file_system_filedir();
 192          $this->assertTrue($vfileroot->hasChild('filedir/warning.txt'));
 193          $this->assertEquals(
 194              'This directory contains the content of uploaded files and is controlled by Moodle code. ' .
 195                  'Do not manually move, change or rename any of the files and subdirectories here.',
 196              $vfileroot->getChild('filedir/warning.txt')->getContent()
 197          );
 198      }
 199  
 200      /**
 201       * Ensure that the default implementation of get_remote_path_from_hash
 202       * simply calls get_local_path_from_hash.
 203       *
 204       * @covers ::get_remote_path_from_hash
 205       */
 206      public function test_get_remote_path_from_hash() {
 207          $filecontent = 'example content';
 208          $contenthash = \file_storage::hash_from_string($filecontent);
 209          $expectedresult = (object) [];
 210  
 211          $fs = $this->get_testable_mock([
 212              'get_local_path_from_hash',
 213          ]);
 214  
 215          $fs->expects($this->once())
 216              ->method('get_local_path_from_hash')
 217              ->with($this->equalTo($contenthash), $this->equalTo(false))
 218              ->willReturn($expectedresult);
 219  
 220          $method = new \ReflectionMethod(file_system_filedir::class, 'get_remote_path_from_hash');
 221          $method->setAccessible(true);
 222          $result = $method->invokeArgs($fs, [$contenthash]);
 223  
 224          $this->assertEquals($expectedresult, $result);
 225      }
 226  
 227      /**
 228       * Test the stock implementation of get_local_path_from_storedfile_with_recovery with no file found and
 229       * a failed recovery.
 230       *
 231       * @covers ::get_local_path_from_storedfile
 232       */
 233      public function test_get_local_path_from_storedfile_with_recovery() {
 234          $filecontent = 'example content';
 235          $file = $this->get_stored_file($filecontent);
 236          $fs = $this->get_testable_mock([
 237              'get_local_path_from_hash',
 238              'recover_file',
 239          ]);
 240          $filepath = '/path/to/nonexistent/file';
 241  
 242          $fs->method('get_local_path_from_hash')
 243              ->willReturn($filepath);
 244  
 245          $fs->expects($this->once())
 246              ->method('recover_file')
 247              ->with($this->equalTo($file));
 248  
 249          $file = $this->get_stored_file('example content');
 250          $result = $fs->get_local_path_from_storedfile($file, true);
 251  
 252          $this->assertEquals($filepath, $result);
 253      }
 254  
 255      /**
 256       * Test the stock implementation of get_local_path_from_storedfile_with_recovery with no file found and
 257       * a failed recovery.
 258       *
 259       * @covers ::get_local_path_from_storedfile
 260       */
 261      public function test_get_local_path_from_storedfile_without_recovery() {
 262          $filecontent = 'example content';
 263          $file = $this->get_stored_file($filecontent);
 264          $fs = $this->get_testable_mock([
 265              'get_local_path_from_hash',
 266              'recover_file',
 267          ]);
 268          $filepath = '/path/to/nonexistent/file';
 269  
 270          $fs->method('get_local_path_from_hash')
 271              ->willReturn($filepath);
 272  
 273          $fs->expects($this->never())
 274              ->method('recover_file');
 275  
 276          $file = $this->get_stored_file('example content');
 277          $result = $fs->get_local_path_from_storedfile($file, false);
 278  
 279          $this->assertEquals($filepath, $result);
 280      }
 281  
 282      /**
 283       * Test that the correct path is generated for the supplied content
 284       * hashes.
 285       *
 286       * @dataProvider contenthash_dataprovider
 287       * @param   string  $hash contenthash to test
 288       * @param   string  $hashdir Expected format of content directory
 289       *
 290       * @covers ::get_fulldir_from_hash
 291       */
 292      public function test_get_fulldir_from_hash($hash, $hashdir) {
 293          global $CFG;
 294  
 295          $fs = new file_system_filedir();
 296          $method = new \ReflectionMethod(file_system_filedir::class, 'get_fulldir_from_hash');
 297          $method->setAccessible(true);
 298          $result = $method->invokeArgs($fs, array($hash));
 299  
 300          $expectedpath = sprintf('%s/filedir/%s', $CFG->dataroot, $hashdir);
 301          $this->assertEquals($expectedpath, $result);
 302      }
 303  
 304      /**
 305       * Test that the correct path is generated for the supplied content
 306       * hashes when used with a stored_file.
 307       *
 308       * @dataProvider contenthash_dataprovider
 309       * @param   string  $hash contenthash to test
 310       * @param   string  $hashdir Expected format of content directory
 311       *
 312       * @covers ::get_fulldir_from_storedfile
 313       */
 314      public function test_get_fulldir_from_storedfile($hash, $hashdir) {
 315          global $CFG;
 316  
 317          $file = $this->getMockBuilder('stored_file')
 318              ->disableOriginalConstructor()
 319              ->onlyMethods([
 320                  'sync_external_file',
 321                  'get_contenthash',
 322              ])
 323              ->getMock();
 324  
 325          $file->method('get_contenthash')->willReturn($hash);
 326  
 327          $fs = new file_system_filedir();
 328          $method = new \ReflectionMethod('file_system_filedir', 'get_fulldir_from_storedfile');
 329          $method->setAccessible(true);
 330          $result = $method->invokeArgs($fs, array($file));
 331  
 332          $expectedpath = sprintf('%s/filedir/%s', $CFG->dataroot, $hashdir);
 333          $this->assertEquals($expectedpath, $result);
 334      }
 335  
 336      /**
 337       * Test that the correct content directory is generated for the supplied
 338       * content hashes.
 339       *
 340       * @dataProvider contenthash_dataprovider
 341       * @param   string  $hash contenthash to test
 342       * @param   string  $hashdir Expected format of content directory
 343       *
 344       * @covers ::get_contentdir_from_hash
 345       */
 346      public function test_get_contentdir_from_hash($hash, $hashdir) {
 347          $method = new \ReflectionMethod(file_system_filedir::class, 'get_contentdir_from_hash');
 348          $method->setAccessible(true);
 349  
 350          $fs = new file_system_filedir();
 351          $result = $method->invokeArgs($fs, array($hash));
 352  
 353          $this->assertEquals($hashdir, $result);
 354      }
 355  
 356      /**
 357       * Test that the correct content path is generated for the supplied
 358       * content hashes.
 359       *
 360       * @dataProvider contenthash_dataprovider
 361       * @param   string  $hash contenthash to test
 362       * @param   string  $hashdir Expected format of content directory
 363       *
 364       * @covers ::get_contentpath_from_hash
 365       */
 366      public function test_get_contentpath_from_hash($hash, $hashdir) {
 367          $method = new \ReflectionMethod(file_system_filedir::class, 'get_contentpath_from_hash');
 368          $method->setAccessible(true);
 369  
 370          $fs = new file_system_filedir();
 371          $result = $method->invokeArgs($fs, array($hash));
 372  
 373          $expectedpath = sprintf('%s/%s', $hashdir, $hash);
 374          $this->assertEquals($expectedpath, $result);
 375      }
 376  
 377      /**
 378       * Test that the correct trash path is generated for the supplied
 379       * content hashes.
 380       *
 381       * @dataProvider contenthash_dataprovider
 382       * @param   string  $hash contenthash to test
 383       * @param   string  $hashdir Expected format of content directory
 384       *
 385       * @covers ::get_trash_fullpath_from_hash
 386       */
 387      public function test_get_trash_fullpath_from_hash($hash, $hashdir) {
 388          global $CFG;
 389  
 390          $fs = new file_system_filedir();
 391          $method = new \ReflectionMethod(file_system_filedir::class, 'get_trash_fullpath_from_hash');
 392          $method->setAccessible(true);
 393          $result = $method->invokeArgs($fs, array($hash));
 394  
 395          $expectedpath = sprintf('%s/trashdir/%s/%s', $CFG->dataroot, $hashdir, $hash);
 396          $this->assertEquals($expectedpath, $result);
 397      }
 398  
 399      /**
 400       * Test that the correct trash directory is generated for the supplied
 401       * content hashes.
 402       *
 403       * @dataProvider contenthash_dataprovider
 404       * @param   string  $hash contenthash to test
 405       * @param   string  $hashdir Expected format of content directory
 406       *
 407       * @covers ::get_trash_fulldir_from_hash
 408       */
 409      public function test_get_trash_fulldir_from_hash($hash, $hashdir) {
 410          global $CFG;
 411  
 412          $fs = new file_system_filedir();
 413          $method = new \ReflectionMethod(file_system_filedir::class, 'get_trash_fulldir_from_hash');
 414          $method->setAccessible(true);
 415          $result = $method->invokeArgs($fs, array($hash));
 416  
 417          $expectedpath = sprintf('%s/trashdir/%s', $CFG->dataroot, $hashdir);
 418          $this->assertEquals($expectedpath, $result);
 419      }
 420  
 421      /**
 422       * Ensure that copying a file to a target from a stored_file works as anticipated.
 423       *
 424       * @covers ::copy_content_from_storedfile
 425       */
 426      public function test_copy_content_from_storedfile() {
 427          $this->resetAfterTest();
 428          global $CFG;
 429  
 430          $filecontent = 'example content';
 431          $contenthash = \file_storage::hash_from_string($filecontent);
 432          $filedircontent = [
 433              $contenthash => $filecontent,
 434          ];
 435          $vfileroot = $this->setup_vfile_root($filedircontent, [], []);
 436  
 437          $fs = $this->getMockBuilder(file_system_filedir::class)
 438              ->disableOriginalConstructor()
 439              ->onlyMethods([
 440                  'get_local_path_from_storedfile',
 441              ])
 442              ->getMock();
 443  
 444          $file = $this->getMockBuilder(\stored_file::class)
 445              ->disableOriginalConstructor()
 446              ->getMock();
 447  
 448          $sourcefile = \org\bovigo\vfs\vfsStream::url('root/filedir/' . $contenthash);
 449          $fs->method('get_local_path_from_storedfile')->willReturn($sourcefile);
 450  
 451          $targetfile = \org\bovigo\vfs\vfsStream::url('root/targetfile');
 452          $CFG->preventfilelocking = true;
 453          $result = $fs->copy_content_from_storedfile($file, $targetfile);
 454  
 455          $this->assertTrue($result);
 456          $this->assertEquals($filecontent, $vfileroot->getChild('targetfile')->getContent());
 457      }
 458  
 459      /**
 460       * Ensure that content recovery works.
 461       *
 462       * @covers ::recover_file
 463       */
 464      public function test_recover_file() {
 465          $this->resetAfterTest();
 466  
 467          // Setup the filedir.
 468          // This contains a virtual file which has a cache mismatch.
 469          $filecontent = 'example content';
 470          $contenthash = \file_storage::hash_from_string($filecontent);
 471  
 472          $trashdircontent = [
 473              '0f' => [
 474                  'f3' => [
 475                      $contenthash => $filecontent,
 476                  ],
 477              ],
 478          ];
 479  
 480          $vfileroot = $this->setup_vfile_root([], $trashdircontent);
 481  
 482          $file = new \stored_file(get_file_storage(), (object) [
 483              'contenthash' => $contenthash,
 484              'filesize' => strlen($filecontent),
 485          ]);
 486  
 487          $fs = new file_system_filedir();
 488          $method = new \ReflectionMethod(file_system_filedir::class, 'recover_file');
 489          $method->setAccessible(true);
 490          $result = $method->invokeArgs($fs, array($file));
 491  
 492          // Test the output.
 493          $this->assertTrue($result);
 494  
 495          $this->assertEquals($filecontent, $vfileroot->getChild('filedir/0f/f3/' . $contenthash)->getContent());
 496  
 497      }
 498  
 499      /**
 500       * Ensure that content recovery works.
 501       *
 502       * @covers ::recover_file
 503       */
 504      public function test_recover_file_already_present() {
 505          $this->resetAfterTest();
 506  
 507          // Setup the filedir.
 508          // This contains a virtual file which has a cache mismatch.
 509          $filecontent = 'example content';
 510          $contenthash = \file_storage::hash_from_string($filecontent);
 511  
 512          $filedircontent = $trashdircontent = [
 513              '0f' => [
 514                  'f3' => [
 515                      $contenthash => $filecontent,
 516                  ],
 517              ],
 518          ];
 519  
 520          $vfileroot = $this->setup_vfile_root($filedircontent, $trashdircontent);
 521  
 522          $file = new \stored_file(get_file_storage(), (object) [
 523              'contenthash' => $contenthash,
 524              'filesize' => strlen($filecontent),
 525          ]);
 526  
 527          $fs = new file_system_filedir();
 528          $method = new \ReflectionMethod(file_system_filedir::class, 'recover_file');
 529          $method->setAccessible(true);
 530          $result = $method->invokeArgs($fs, array($file));
 531  
 532          // Test the output.
 533          $this->assertTrue($result);
 534  
 535          $this->assertEquals($filecontent, $vfileroot->getChild('filedir/0f/f3/' . $contenthash)->getContent());
 536      }
 537  
 538      /**
 539       * Ensure that content recovery works.
 540       *
 541       * @covers ::recover_file
 542       */
 543      public function test_recover_file_size_mismatch() {
 544          $this->resetAfterTest();
 545  
 546          // Setup the filedir.
 547          // This contains a virtual file which has a cache mismatch.
 548          $filecontent = 'example content';
 549          $contenthash = \file_storage::hash_from_string($filecontent);
 550  
 551          $trashdircontent = [
 552              '0f' => [
 553                  'f3' => [
 554                      $contenthash => $filecontent,
 555                  ],
 556              ],
 557          ];
 558          $vfileroot = $this->setup_vfile_root([], $trashdircontent);
 559  
 560          $file = new \stored_file(get_file_storage(), (object) [
 561              'contenthash' => $contenthash,
 562              'filesize' => strlen($filecontent) + 1,
 563          ]);
 564  
 565          $fs = new file_system_filedir();
 566          $method = new \ReflectionMethod(file_system_filedir::class, 'recover_file');
 567          $method->setAccessible(true);
 568          $result = $method->invokeArgs($fs, array($file));
 569  
 570          // Test the output.
 571          $this->assertFalse($result);
 572          $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
 573      }
 574  
 575      /**
 576       * Ensure that content recovery works.
 577       *
 578       * @covers ::recover_file
 579       */
 580      public function test_recover_file_has_mismatch() {
 581          $this->resetAfterTest();
 582  
 583          // Setup the filedir.
 584          // This contains a virtual file which has a cache mismatch.
 585          $filecontent = 'example content';
 586          $contenthash = \file_storage::hash_from_string($filecontent);
 587  
 588          $trashdircontent = [
 589              '0f' => [
 590                  'f3' => [
 591                      $contenthash => $filecontent,
 592                  ],
 593              ],
 594          ];
 595          $vfileroot = $this->setup_vfile_root([], $trashdircontent);
 596  
 597          $file = new \stored_file(get_file_storage(), (object) [
 598              'contenthash' => $contenthash . " different",
 599              'filesize' => strlen($filecontent),
 600          ]);
 601  
 602          $fs = new file_system_filedir();
 603          $method = new \ReflectionMethod(file_system_filedir::class, 'recover_file');
 604          $method->setAccessible(true);
 605          $result = $method->invokeArgs($fs, array($file));
 606  
 607          // Test the output.
 608          $this->assertFalse($result);
 609          $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
 610      }
 611  
 612      /**
 613       * Ensure that content recovery works when the content file is in the
 614       * alt trash directory.
 615       *
 616       * @covers ::recover_file
 617       */
 618      public function test_recover_file_alttrash() {
 619          $this->resetAfterTest();
 620  
 621          // Setup the filedir.
 622          // This contains a virtual file which has a cache mismatch.
 623          $filecontent = 'example content';
 624          $contenthash = \file_storage::hash_from_string($filecontent);
 625  
 626          $trashdircontent = [
 627              $contenthash => $filecontent,
 628          ];
 629          $vfileroot = $this->setup_vfile_root([], $trashdircontent);
 630  
 631          $file = new \stored_file(get_file_storage(), (object) [
 632              'contenthash' => $contenthash,
 633              'filesize' => strlen($filecontent),
 634          ]);
 635  
 636          $fs = new file_system_filedir();
 637          $method = new \ReflectionMethod(file_system_filedir::class, 'recover_file');
 638          $method->setAccessible(true);
 639          $result = $method->invokeArgs($fs, array($file));
 640  
 641          // Test the output.
 642          $this->assertTrue($result);
 643  
 644          $this->assertEquals($filecontent, $vfileroot->getChild('filedir/0f/f3/' . $contenthash)->getContent());
 645      }
 646  
 647      /**
 648       * Test that an appropriate error message is generated when adding a
 649       * file to the pool when the pool directory structure is not writable.
 650       *
 651       * @covers ::recover_file
 652       */
 653      public function test_recover_file_contentdir_readonly() {
 654          $this->resetAfterTest();
 655  
 656          $filecontent = 'example content';
 657          $contenthash = \file_storage::hash_from_string($filecontent);
 658          $filedircontent = [
 659              '0f' => [],
 660          ];
 661          $trashdircontent = [
 662              $contenthash => $filecontent,
 663          ];
 664          $vfileroot = $this->setup_vfile_root($filedircontent, $trashdircontent);
 665  
 666          // Make the target path readonly.
 667          $vfileroot->getChild('filedir/0f')
 668              ->chmod(0444)
 669              ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
 670  
 671          $file = new \stored_file(get_file_storage(), (object) [
 672              'contenthash' => $contenthash,
 673              'filesize' => strlen($filecontent),
 674          ]);
 675  
 676          $fs = new file_system_filedir();
 677          $method = new \ReflectionMethod(file_system_filedir::class, 'recover_file');
 678          $method->setAccessible(true);
 679          $result = $method->invokeArgs($fs, array($file));
 680  
 681          // Test the output.
 682          $this->assertFalse($result);
 683      }
 684  
 685      /**
 686       * Test adding a file to the pool.
 687       *
 688       * @covers ::add_file_from_path
 689       */
 690      public function test_add_file_from_path() {
 691          $this->resetAfterTest();
 692          global $CFG;
 693  
 694          // Setup the filedir.
 695          // This contains a virtual file which has a cache mismatch.
 696          $filecontent = 'example content';
 697          $contenthash = \file_storage::hash_from_string($filecontent);
 698          $sourcedircontent = [
 699              'file' => $filecontent,
 700          ];
 701  
 702          $vfileroot = $this->setup_vfile_root([], [], $sourcedircontent);
 703  
 704          // Note, the vfs file system does not support locks - prevent file locking here.
 705          $CFG->preventfilelocking = true;
 706  
 707          // Attempt to add the file to the file pool.
 708          $fs = new file_system_filedir();
 709          $sourcefile = \org\bovigo\vfs\vfsStream::url('root/sourcedir/file');
 710          $result = $fs->add_file_from_path($sourcefile);
 711  
 712          // Test the output.
 713          $this->assertEquals($contenthash, $result[0]);
 714          $this->assertEquals(\core_text::strlen($filecontent), $result[1]);
 715          $this->assertTrue($result[2]);
 716  
 717          $this->assertEquals($filecontent, $vfileroot->getChild('filedir/0f/f3/' . $contenthash)->getContent());
 718      }
 719  
 720      /**
 721       * Test that an appropriate error message is generated when adding an
 722       * unavailable file to the pool is attempted.
 723       *
 724       * @covers ::add_file_from_path
 725       */
 726      public function test_add_file_from_path_file_unavailable() {
 727          $this->resetAfterTest();
 728  
 729          // Setup the filedir.
 730          $vfileroot = $this->setup_vfile_root();
 731  
 732          $this->expectException('file_exception');
 733          $this->expectExceptionMessageMatches(
 734              '/Cannot read file\. Either the file does not exist or there is a permission problem\./');
 735  
 736          $fs = new file_system_filedir();
 737          $fs->add_file_from_path(\org\bovigo\vfs\vfsStream::url('filedir/file'));
 738      }
 739  
 740      /**
 741       * Test that an appropriate error message is generated when specifying
 742       * the wrong contenthash when adding a file to the pool.
 743       *
 744       * @covers ::add_file_from_path
 745       */
 746      public function test_add_file_from_path_mismatched_hash() {
 747          $this->resetAfterTest();
 748  
 749          $filecontent = 'example content';
 750          $contenthash = \file_storage::hash_from_string($filecontent);
 751          $sourcedir = [
 752              'file' => $filecontent,
 753          ];
 754          $vfileroot = $this->setup_vfile_root([], [], $sourcedir);
 755  
 756          $fs = new file_system_filedir();
 757          $filepath = \org\bovigo\vfs\vfsStream::url('root/sourcedir/file');
 758          $fs->add_file_from_path($filepath, 'eee4943847a35a4b6942c6f96daafde06bcfdfab');
 759          $this->assertDebuggingCalled("Invalid contenthash submitted for file $filepath");
 760      }
 761  
 762      /**
 763       * Test that an appropriate error message is generated when an existing
 764       * file in the pool has the wrong contenthash
 765       *
 766       * @covers ::add_file_from_path
 767       */
 768      public function test_add_file_from_path_existing_content_invalid() {
 769          $this->resetAfterTest();
 770  
 771          $filecontent = 'example content';
 772          $contenthash = \file_storage::hash_from_string($filecontent);
 773          $filedircontent = [
 774              '0f' => [
 775                  'f3' => [
 776                      // This contains a virtual file which has a cache mismatch.
 777                      '0ff30941ca5acd879fd809e8c937d9f9e6dd1615' => 'different example content',
 778                  ],
 779              ],
 780          ];
 781          $sourcedir = [
 782              'file' => $filecontent,
 783          ];
 784          $vfileroot = $this->setup_vfile_root($filedircontent, [], $sourcedir);
 785  
 786          // Check that we hit the jackpot.
 787          $fs = new file_system_filedir();
 788          $filepath = \org\bovigo\vfs\vfsStream::url('root/sourcedir/file');
 789          $result = $fs->add_file_from_path($filepath);
 790  
 791          // We provided a bad hash. Check that the file was replaced.
 792          $this->assertDebuggingCalled("Replacing invalid content file $contenthash");
 793  
 794          // Test the output.
 795          $this->assertEquals($contenthash, $result[0]);
 796          $this->assertEquals(\core_text::strlen($filecontent), $result[1]);
 797          $this->assertFalse($result[2]);
 798  
 799          // Fetch the new file structure.
 800          $structure = \org\bovigo\vfs\vfsStream::inspect(
 801              new \org\bovigo\vfs\visitor\vfsStreamStructureVisitor()
 802          )->getStructure();
 803  
 804          $this->assertEquals($filecontent, $structure['root']['filedir']['0f']['f3'][$contenthash]);
 805      }
 806  
 807      /**
 808       * Test that an appropriate error message is generated when adding a
 809       * file to the pool when the pool directory structure is not writable.
 810       *
 811       * @covers ::add_file_from_path
 812       */
 813      public function test_add_file_from_path_existing_cannot_write_hashpath() {
 814          $this->resetAfterTest();
 815  
 816          $filecontent = 'example content';
 817          $contenthash = \file_storage::hash_from_string($filecontent);
 818          $filedircontent = [
 819              '0f' => [],
 820          ];
 821          $sourcedir = [
 822              'file' => $filecontent,
 823          ];
 824          $vfileroot = $this->setup_vfile_root($filedircontent, [], $sourcedir);
 825  
 826          // Make the target path readonly.
 827          $vfileroot->getChild('filedir/0f')
 828              ->chmod(0444)
 829              ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
 830  
 831          $this->expectException('file_exception');
 832          $this->expectExceptionMessageMatches(
 833              "/Cannot create local file pool directories. Please verify permissions in dataroot./");
 834  
 835          // Attempt to add the file to the file pool.
 836          $fs = new file_system_filedir();
 837          $sourcefile = \org\bovigo\vfs\vfsStream::url('root/sourcedir/file');
 838          $fs->add_file_from_path($sourcefile);
 839      }
 840  
 841      /**
 842       * Test adding a string to the pool.
 843       *
 844       * @covers ::add_file_from_string
 845       */
 846      public function test_add_file_from_string() {
 847          $this->resetAfterTest();
 848          global $CFG;
 849  
 850          $filecontent = 'example content';
 851          $contenthash = \file_storage::hash_from_string($filecontent);
 852          $vfileroot = $this->setup_vfile_root();
 853  
 854          // Note, the vfs file system does not support locks - prevent file locking here.
 855          $CFG->preventfilelocking = true;
 856  
 857          // Attempt to add the file to the file pool.
 858          $fs = new file_system_filedir();
 859          $result = $fs->add_file_from_string($filecontent);
 860  
 861          // Test the output.
 862          $this->assertEquals($contenthash, $result[0]);
 863          $this->assertEquals(\core_text::strlen($filecontent), $result[1]);
 864          $this->assertTrue($result[2]);
 865      }
 866  
 867      /**
 868       * Test that an appropriate error message is generated when adding a
 869       * string to the pool when the pool directory structure is not writable.
 870       *
 871       * @covers ::add_file_from_string
 872       */
 873      public function test_add_file_from_string_existing_cannot_write_hashpath() {
 874          $this->resetAfterTest();
 875  
 876          $filecontent = 'example content';
 877          $contenthash = \file_storage::hash_from_string($filecontent);
 878  
 879          $filedircontent = [
 880              '0f' => [],
 881          ];
 882          $vfileroot = $this->setup_vfile_root($filedircontent);
 883  
 884          // Make the target path readonly.
 885          $vfileroot->getChild('filedir/0f')
 886              ->chmod(0444)
 887              ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
 888  
 889          $this->expectException('file_exception');
 890          $this->expectExceptionMessageMatches(
 891              "/Cannot create local file pool directories. Please verify permissions in dataroot./");
 892  
 893          // Attempt to add the file to the file pool.
 894          $fs = new file_system_filedir();
 895          $fs->add_file_from_string($filecontent);
 896      }
 897  
 898      /**
 899       * Test adding a string to the pool when an item with the same
 900       * contenthash is already present.
 901       *
 902       * @covers ::add_file_from_string
 903       */
 904      public function test_add_file_from_string_existing_matches() {
 905          $this->resetAfterTest();
 906          global $CFG;
 907  
 908          $filecontent = 'example content';
 909          $contenthash = \file_storage::hash_from_string($filecontent);
 910          $filedircontent = [
 911              '0f' => [
 912                  'f3' => [
 913                      $contenthash => $filecontent,
 914                  ],
 915              ],
 916          ];
 917  
 918          $vfileroot = $this->setup_vfile_root($filedircontent);
 919  
 920          // Note, the vfs file system does not support locks - prevent file locking here.
 921          $CFG->preventfilelocking = true;
 922  
 923          // Attempt to add the file to the file pool.
 924          $fs = new file_system_filedir();
 925          $result = $fs->add_file_from_string($filecontent);
 926  
 927          // Test the output.
 928          $this->assertEquals($contenthash, $result[0]);
 929          $this->assertEquals(\core_text::strlen($filecontent), $result[1]);
 930          $this->assertFalse($result[2]);
 931      }
 932  
 933      /**
 934       * Test the cleanup of deleted files when there are no files to delete.
 935       *
 936       * @covers ::remove_file
 937       */
 938      public function test_remove_file_missing() {
 939          $this->resetAfterTest();
 940  
 941          $filecontent = 'example content';
 942          $contenthash = \file_storage::hash_from_string($filecontent);
 943          $vfileroot = $this->setup_vfile_root();
 944  
 945          $fs = new file_system_filedir();
 946          $fs->remove_file($contenthash);
 947  
 948          $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
 949          // No file to move to trash, so the trash path will also be empty.
 950          $this->assertFalse($vfileroot->hasChild('trashdir/0f'));
 951          $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3'));
 952          $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
 953      }
 954  
 955      /**
 956       * Test the cleanup of deleted files when a file already exists in the
 957       * trash for that path.
 958       *
 959       * @covers ::remove_file
 960       */
 961      public function test_remove_file_existing_trash() {
 962          $this->resetAfterTest();
 963  
 964          $filecontent = 'example content';
 965          $contenthash = \file_storage::hash_from_string($filecontent);
 966  
 967          $filedircontent = $trashdircontent = [
 968              '0f' => [
 969                  'f3' => [
 970                      $contenthash => $filecontent,
 971                  ],
 972              ],
 973          ];
 974          $trashdircontent['0f']['f3'][$contenthash] .= 'different';
 975          $vfileroot = $this->setup_vfile_root($filedircontent, $trashdircontent);
 976  
 977          $fs = new file_system_filedir();
 978          $fs->remove_file($contenthash);
 979  
 980          $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
 981          $this->assertTrue($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
 982          $this->assertNotEquals($filecontent, $vfileroot->getChild('trashdir/0f/f3/' . $contenthash)->getContent());
 983      }
 984  
 985      /**
 986       * Ensure that remove_file does nothing with an empty file.
 987       *
 988       * @covers ::remove_file
 989       */
 990      public function test_remove_file_empty() {
 991          $this->resetAfterTest();
 992          global $DB;
 993  
 994          $DB = $this->getMockBuilder(\moodle_database::class)
 995              ->onlyMethods(['record_exists'])
 996              ->getMockForAbstractClass();
 997  
 998          $DB->expects($this->never())
 999              ->method('record_exists');
1000  
1001          $fs = new file_system_filedir();
1002  
1003          $result = $fs->remove_file(\file_storage::hash_from_string(''));
1004          $this->assertNull($result);
1005      }
1006  
1007      /**
1008       * Ensure that remove_file does nothing when a file is still
1009       * in use.
1010       *
1011       * @covers ::remove_file
1012       */
1013      public function test_remove_file_in_use() {
1014          $this->resetAfterTest();
1015          global $DB;
1016  
1017          $filecontent = 'example content';
1018          $contenthash = \file_storage::hash_from_string($filecontent);
1019          $filedircontent = [
1020              '0f' => [
1021                  'f3' => [
1022                      $contenthash => $filecontent,
1023                  ],
1024              ],
1025          ];
1026          $vfileroot = $this->setup_vfile_root($filedircontent);
1027  
1028          $DB = $this->getMockBuilder(\moodle_database::class)
1029              ->onlyMethods(['record_exists'])
1030              ->getMockForAbstractClass();
1031  
1032          $DB->method('record_exists')->willReturn(true);
1033  
1034          $fs = new file_system_filedir();
1035          $result = $fs->remove_file($contenthash);
1036          $this->assertTrue($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
1037          $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
1038      }
1039  
1040      /**
1041       * Ensure that remove_file removes the file when it is no
1042       * longer in use.
1043       *
1044       * @covers ::remove_file
1045       */
1046      public function test_remove_file_expired() {
1047          $this->resetAfterTest();
1048          global $DB;
1049  
1050          $filecontent = 'example content';
1051          $contenthash = \file_storage::hash_from_string($filecontent);
1052          $filedircontent = [
1053              '0f' => [
1054                  'f3' => [
1055                      $contenthash => $filecontent,
1056                  ],
1057              ],
1058          ];
1059          $vfileroot = $this->setup_vfile_root($filedircontent);
1060  
1061          $DB = $this->getMockBuilder(\moodle_database::class)
1062              ->onlyMethods(['record_exists'])
1063              ->getMockForAbstractClass();
1064  
1065          $DB->method('record_exists')->willReturn(false);
1066  
1067          $fs = new file_system_filedir();
1068          $result = $fs->remove_file($contenthash);
1069          $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
1070          $this->assertTrue($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
1071      }
1072  
1073      /**
1074       * Test purging the cache.
1075       *
1076       * @covers ::empty_trash
1077       */
1078      public function test_empty_trash() {
1079          $this->resetAfterTest();
1080  
1081          $filecontent = 'example content';
1082          $contenthash = \file_storage::hash_from_string($filecontent);
1083  
1084          $filedircontent = $trashdircontent = [
1085              '0f' => [
1086                  'f3' => [
1087                      $contenthash => $filecontent,
1088                  ],
1089              ],
1090          ];
1091          $vfileroot = $this->setup_vfile_root($filedircontent, $trashdircontent);
1092  
1093          $fs = new file_system_filedir();
1094          $method = new \ReflectionMethod(file_system_filedir::class, 'empty_trash');
1095          $method->setAccessible(true);
1096          $result = $method->invoke($fs);
1097  
1098          $this->assertTrue($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
1099          $this->assertFalse($vfileroot->hasChild('trashdir'));
1100          $this->assertFalse($vfileroot->hasChild('trashdir/0f'));
1101          $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3'));
1102          $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
1103      }
1104  
1105      /**
1106       * Data Provider for contenthash to contendir conversion.
1107       *
1108       * @return  array
1109       */
1110      public function contenthash_dataprovider() {
1111          return array(
1112              array(
1113                  'contenthash'   => 'eee4943847a35a4b6942c6f96daafde06bcfdfab',
1114                  'contentdir'    => 'ee/e4',
1115              ),
1116              array(
1117                  'contenthash'   => 'aef05a62ae81ca0005d2569447779af062b7cda0',
1118                  'contentdir'    => 'ae/f0',
1119              ),
1120          );
1121      }
1122  }