diff --git a/h5p/classes/helper.php b/h5p/classes/helper.php new file mode 100644 index 00000000000..7d50a27a314 --- /dev/null +++ b/h5p/classes/helper.php @@ -0,0 +1,185 @@ +. + +/** + * Contains helper class for the H5P area. + * + * @package core_h5p + * @copyright 2019 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_h5p; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Helper class for the H5P area. + * + * @copyright 2019 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + + /** + * Store an H5P file. + * + * @param factory $factory The \core_h5p\factory object + * @param stored_file $file Moodle file instance + * @param stdClass $config Button options config + * @param bool $onlyupdatelibs Whether new libraries can be installed or only the existing ones can be updated + * @param bool $skipcontent Should the content be skipped (so only the libraries will be saved)? + * + * @return int|false|null The H5P identifier or null if there is an error when saving or false if it's not a valid H5P package + */ + public static function save_h5p(factory $factory, \stored_file $file, \stdClass $config, bool $onlyupdatelibs = false, + bool $skipcontent = false) { + // This may take a long time. + \core_php_time_limit::raise(); + + $core = $factory->get_core(); + $path = $core->fs->getTmpPath(); + $core->h5pF->getUploadedH5pFolderPath($path); + // Add manually the extension to the file to avoid the validation fails. + $path .= '.h5p'; + $core->h5pF->getUploadedH5pPath($path); + + // Copy the .h5p file to the temporary folder. + $file->copy_content_to($path); + + // Check if the h5p file is valid before saving it. + $h5pvalidator = $factory->get_validator(); + if ($h5pvalidator->isValidPackage($skipcontent, $onlyupdatelibs)) { + $h5pstorage = $factory->get_storage(); + + $content = [ + 'pathnamehash' => $file->get_pathnamehash(), + 'contenthash' => $file->get_contenthash(), + ]; + $options = ['disable' => self::get_display_options($core, $config)]; + + $h5pstorage->savePackage($content, null, $skipcontent, $options); + + return $h5pstorage->contentId; + } + + return false; + } + + + /** + * Get the representation of display options as int. + * + * @param core $core The \core_h5p\core object + * @param stdClass $config Button options config + * + * @return int The representation of display options as int + */ + public static function get_display_options(core $core, \stdClass $config): int { + $export = isset($config->export) ? $config->export : 0; + $embed = isset($config->embed) ? $config->embed : 0; + $copyright = isset($config->copyright) ? $config->copyright : 0; + $frame = ($export || $embed || $copyright); + if (!$frame) { + $frame = isset($config->frame) ? $config->frame : 0; + } + + $disableoptions = [ + core::DISPLAY_OPTION_FRAME => $frame, + core::DISPLAY_OPTION_DOWNLOAD => $export, + core::DISPLAY_OPTION_EMBED => $embed, + core::DISPLAY_OPTION_COPYRIGHT => $copyright, + ]; + + return $core->getStorableDisplayOptions($disableoptions, 0); + } + + /** + * Checks if the author of the .h5p file is "trustable". If the file hasn't been uploaded by a user with the + * required capability, the content won't be deployed. + * + * @param stored_file $file The .h5p file to be deployed + * @return bool Returns true if the file can be deployed, false otherwise. + */ + public static function can_deploy_package(\stored_file $file): bool { + if (is_null($file)) { + debugging('\core_h5p\h5p::can_deploy_package() now expects a \'file\' to be passed.', + DEBUG_DEVELOPER); + return false; + } + + $context = \context::instance_by_id($file->get_contextid()); + if (has_capability('moodle/h5p:deploy', $context, $file->get_userid())) { + return true; + } + + return false; + } + + /** + * Checks if the content-type libraries can be upgraded. + * The H5P content-type libraries can only be upgraded if the author of the .h5p file can manage content-types or if all the + * content-types exist, to avoid users without the required capability to upload malicious content. + * + * @param stored_file $file The .h5p file to be deployed + * @return bool Returns true if the content-type libraries can be created/updated, false otherwise. + */ + public static function can_update_library(\stored_file $file): bool { + if (is_null($file)) { + debugging('\core_h5p\h5p::can_update_library() now expects a \'file\' to be passed.', + DEBUG_DEVELOPER); + return false; + } + + $context = \context::instance_by_id($file->get_contextid()); + // Check if the owner of the .h5p file has the capability to manage content-types. + if (has_capability('moodle/h5p:updatelibraries', $context, $file->get_userid())) { + return true; + } + + return false; + } + + /** + * Convenience to take a fixture test file and create a stored_file. + * + * @param string $filepath The filepath of the file + * @param int $userid The author of the file + * @param \context $context The context where the file will be created + * @return stored_file The file created + */ + public static function create_fake_stored_file_from_path(string $filepath, int $userid = 0, + \context $context = null): \stored_file { + if (is_null($context)) { + $context = \context_system::instance(); + } + $filerecord = [ + 'contextid' => $context->id, + 'component' => 'core_h5p', + 'filearea' => 'unittest', + 'itemid' => rand(), + 'filepath' => '/', + 'filename' => basename($filepath), + ]; + if (!is_null($userid)) { + $filerecord['userid'] = $userid; + } + + $fs = get_file_storage(); + return $fs->create_file_from_pathname($filerecord, $filepath); + } + +} diff --git a/h5p/tests/fixtures/greeting-card-887.h5p b/h5p/tests/fixtures/greeting-card-887.h5p new file mode 100644 index 00000000000..40d3093ed4e Binary files /dev/null and b/h5p/tests/fixtures/greeting-card-887.h5p differ diff --git a/h5p/tests/helper_test.php b/h5p/tests/helper_test.php new file mode 100644 index 00000000000..932375720af --- /dev/null +++ b/h5p/tests/helper_test.php @@ -0,0 +1,286 @@ +. + +/** + * Testing the H5P helper. + * + * @package core_h5p + * @category test + * @copyright 2019 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types = 1); + +namespace core_h5p; + +use advanced_testcase; + +/** + * Test class covering the H5P helper. + * + * @package core_h5p + * @copyright 2019 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper_testcase extends \advanced_testcase { + + /** + * Test the behaviour of get_display_options(). + * + * @dataProvider get_display_options_provider + * @param bool $frame Whether the frame should be displayed or not + * @param bool $export Whether the export action button should be displayed or not + * @param bool $embed Whether the embed action button should be displayed or not + * @param bool $copyright Whether the copyright action button should be displayed or not + * @param int $expected The expectation with the displayoptions value + */ + public function test_get_display_options(bool $frame, bool $export, bool $embed, bool $copyright, int $expected): void { + $this->setRunTestInSeparateProcess(true); + $this->resetAfterTest(); + + $factory = new \core_h5p\factory(); + $core = $factory->get_core(); + $config = (object)[ + 'frame' => $frame, + 'export' => $export, + 'embed' => $embed, + 'copyright' => $copyright, + ]; + $displayoptions = helper::get_display_options($core, $config); + + $this->assertEquals($expected, $displayoptions); + } + + /** + * Data provider for test_get_display_options(). + * + * @return array + */ + public function get_display_options_provider(): array { + return [ + 'All display options disabled' => [ + false, + false, + false, + false, + 15, + ], + 'All display options enabled' => [ + true, + true, + true, + true, + 0, + ], + 'Frame disabled and the rest enabled' => [ + false, + true, + true, + true, + 0, + ], + 'Only export enabled' => [ + false, + true, + false, + false, + 12, + ], + 'Only embed enabled' => [ + false, + false, + true, + false, + 10, + ], + 'Only copyright enabled' => [ + false, + false, + false, + true, + 6, + ], + ]; + } + + /** + * Test the behaviour of save_h5p() when there are some missing libraries in the system. + * @runInSeparateProcess + */ + public function test_save_h5p_missing_libraries(): void { + $this->resetAfterTest(); + $factory = new \core_h5p\factory(); + + // Create a user. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + // This is a valid .H5P file. + $path = __DIR__ . '/fixtures/greeting-card-887.h5p'; + $file = helper::create_fake_stored_file_from_path($path, (int)$user->id); + $factory->get_framework()->set_file($file); + + $config = (object)[ + 'frame' => 1, + 'export' => 1, + 'embed' => 0, + 'copyright' => 0, + ]; + + // There are some missing libraries in the system, so an error should be returned. + $h5pid = helper::save_h5p($factory, $file, $config); + $this->assertFalse($h5pid); + $errors = $factory->get_framework()->getMessages('error'); + $this->assertCount(1, $errors); + $error = reset($errors); + $this->assertEquals('missing-required-library', $error->code); + $this->assertEquals('Missing required library H5P.GreetingCard 1.0', $error->message); + } + + /** + * Test the behaviour of save_h5p() when the libraries exist in the system. + * @runInSeparateProcess + */ + public function test_save_h5p_existing_libraries(): void { + global $DB; + + $this->resetAfterTest(); + $factory = new \core_h5p\factory(); + + // Create a user. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + // This is a valid .H5P file. + $path = __DIR__ . '/fixtures/greeting-card-887.h5p'; + $file = helper::create_fake_stored_file_from_path($path, (int)$user->id); + $factory->get_framework()->set_file($file); + + $config = (object)[ + 'frame' => 1, + 'export' => 1, + 'embed' => 0, + 'copyright' => 0, + ]; + // The required libraries exist in the system before saving the .h5p file. + $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); + $lib = $generator->create_library_record('H5P.GreetingCard', 'GreetingCard', 1, 0); + $h5pid = helper::save_h5p($factory, $file, $config); + $this->assertNotEmpty($h5pid); + + // No errors are raised. + $errors = $factory->get_framework()->getMessages('error'); + $this->assertCount(0, $errors); + + // And the content in the .h5p file has been saved as expected. + $h5p = $DB->get_record('h5p', ['id' => $h5pid]); + $this->assertEquals($lib->id, $h5p->mainlibraryid); + $this->assertEquals(helper::get_display_options($factory->get_core(), $config), $h5p->displayoptions); + $this->assertContains('Hello world!', $h5p->jsoncontent); + } + + /** + * Test the behaviour of save_h5p() when the .h5p file is invalid. + * @runInSeparateProcess + */ + public function test_save_h5p_invalid_file(): void { + $this->resetAfterTest(); + $factory = new \core_h5p\factory(); + + // Create a user. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + // Prepare an invalid .H5P file. + $path = __DIR__ . '/fixtures/h5ptest.zip'; + $file = helper::create_fake_stored_file_from_path($path, (int)$user->id); + $factory->get_framework()->set_file($file); + $config = (object)[ + 'frame' => 1, + 'export' => 1, + 'embed' => 0, + 'copyright' => 0, + ]; + + // When saving an invalid .h5p file, an error should be raised. + $h5pid = helper::save_h5p($factory, $file, $config); + $this->assertFalse($h5pid); + $errors = $factory->get_framework()->getMessages('error'); + $this->assertCount(2, $errors); + + $expectederrorcodes = ['invalid-content-folder', 'invalid-h5p-json-file']; + foreach ($errors as $error) { + $this->assertContains($error->code, $expectederrorcodes); + } + } + + /** + * Test the behaviour of can_deploy_package(). + */ + public function test_can_deploy_package(): void { + $this->resetAfterTest(); + $factory = new \core_h5p\factory(); + + // Create a user. + $user = $this->getDataGenerator()->create_user(); + $admin = get_admin(); + + // Prepare a valid .H5P file. + $path = __DIR__ . '/fixtures/greeting-card-887.h5p'; + + // Files created by users can't be deployed. + $file = helper::create_fake_stored_file_from_path($path, (int)$user->id); + $factory->get_framework()->set_file($file); + $candeploy = helper::can_deploy_package($file); + $this->assertFalse($candeploy); + + // Files created by admins can be deployed, even when the current user is not the admin. + $this->setUser($user); + $file = helper::create_fake_stored_file_from_path($path, (int)$admin->id); + $factory->get_framework()->set_file($file); + $candeploy = helper::can_deploy_package($file); + $this->assertTrue($candeploy); + } + + /** + * Test the behaviour of can_update_library(). + */ + public function can_update_library(): void { + $this->resetAfterTest(); + $factory = new \core_h5p\factory(); + + // Create a user. + $user = $this->getDataGenerator()->create_user(); + $admin = get_admin(); + + // Prepare a valid .H5P file. + $path = __DIR__ . '/fixtures/greeting-card-887.h5p'; + + // Libraries can't be updated when the file has been created by users. + $file = helper::create_fake_stored_file_from_path($path, (int)$user->id); + $factory->get_framework()->set_file($file); + $candeploy = helper::can_update_library($file); + $this->assertFalse($candeploy); + + // Libraries can be updated when the file has been created by admin, even when the current user is not the admin. + $this->setUser($user); + $file = helper::create_fake_stored_file_from_path($path, (int)$admin->id); + $factory->get_framework()->set_file($file); + $candeploy = helper::can_update_library($file); + $this->assertTrue($candeploy); + } +}