From c001ef2820b30161720eb207c836658a500e3aaa Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 12 Aug 2024 17:50:19 +0200 Subject: [PATCH] feat: serve public static files from packages --- classes/api/api.php | 13 ++- classes/api/package_api.php | 115 ++++++++++++++++++-- classes/api/qpy_http_client.php | 59 ++++++++++ classes/external/load_packages.php | 3 +- classes/package_file_service.php | 25 +++++ classes/question_ui_renderer.php | 44 +++++++- classes/static_file_service.php | 83 +++++++++++++++ lib.php | 37 ++++++- tests/question_ui_renderer_test.php | 102 +++++++++++------- tests/question_uis/qpy-urls.xhtml | 21 ++++ tests/static_file_service_test.php | 160 ++++++++++++++++++++++++++++ 11 files changed, 603 insertions(+), 59 deletions(-) create mode 100644 classes/api/qpy_http_client.php create mode 100644 classes/static_file_service.php create mode 100644 tests/question_uis/qpy-urls.xhtml create mode 100644 tests/static_file_service_test.php diff --git a/classes/api/api.php b/classes/api/api.php index 4cfb3824..3e031bde 100644 --- a/classes/api/api.php +++ b/classes/api/api.php @@ -30,6 +30,17 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class api { + /** + * Initialize instance. + * + * @param qpy_http_client $client + */ + public function __construct( + /** @var qpy_http_client */ + private readonly qpy_http_client $client + ) { + } + /** * Retrieves QuestionPy packages from the application server. * @@ -81,7 +92,7 @@ public function get_package(string $hash): ?package_raw { * @return package_api */ public function package(string $hash, ?stored_file $file = null): package_api { - return new package_api($hash, $file); + return new package_api($this->client, $hash, $file); } /** diff --git a/classes/api/package_api.php b/classes/api/package_api.php index d4c1ae61..171613e7 100644 --- a/classes/api/package_api.php +++ b/classes/api/package_api.php @@ -16,7 +16,14 @@ namespace qtype_questionpy\api; +use coding_exception; +use core\http_client; +use GuzzleHttp\Exception\BadResponseException; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\InvalidArgumentException; +use GuzzleHttp\Utils; use moodle_exception; +use Psr\Http\Message\ResponseInterface; use qtype_questionpy\array_converter\array_converter; use stored_file; @@ -31,22 +38,21 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class package_api { - /** @var string */ - private string $hash; - - /** @var stored_file|null */ - private ?stored_file $file; - /** * Initialize a new instance. * - * @param string $hash package hash + * @param string $hash package hash * @param stored_file|null $file package file or null. If this is not provided and the package is not available to * the server, operations will fail */ - public function __construct(string $hash, ?stored_file $file = null) { - $this->hash = $hash; - $this->file = $file; + public function __construct( + /** @var qpy_http_client $client */ + private readonly qpy_http_client $client, + /** @var string $hash */ + private readonly string $hash, + /** @var stored_file|null $file */ + private readonly ?stored_file $file = null + ) { } /** @@ -156,6 +162,94 @@ public function score_attempt(string $questionstate, string $attemptstate, ?stri return array_converter::from_array(attempt_scored::class, $httpresponse->get_data()); } + /** + * Send a POST request and retry if the server doesn't have the package file cached, but we have it available. + * + * @param string $uri can be absolute or relative to the base url + * @param array $options request options as per + * {@link https://docs.guzzlephp.org/en/stable/request-options.html Guzzle docs} + * @param bool $allowretry if set to false, retry won't be attempted if the package file isn't cached, instead + * throwing a {@see coding_exception} + * @return ResponseInterface + * @throws coding_exception if the request is unsuccessful for any other reason + * @see post_and_maybe_retry + */ + private function guzzle_post_and_maybe_retry(string $uri, array $options = [], bool $allowretry = true): ResponseInterface { + try { + return $this->client->post($uri, $options); + } catch (BadResponseException $e) { + if (!$allowretry || !$this->file || $e->getResponse()->getStatusCode() != 404) { + throw $e; + } + + try { + $json = Utils::jsonDecode($e->getResponse()->getBody(), assoc: true); + } catch (InvalidArgumentException) { + // Not valid JSON, so the problem probably isn't a missing package file. + throw $e; + } + + if ($json['what'] ?? null !== 'PACKAGE') { + throw $e; + } + + // Add file to parts and resend. + + $fd = $this->file->get_content_file_handle(); + try { + $options["multipart"][] = [ + "name" => "package", + "contents" => $fd, + ]; + + return $this->guzzle_post_and_maybe_retry($uri, $options, allowretry: false); + } finally { + @fclose($fd); + } + } catch (GuzzleException $e) { + throw new coding_exception("Request to QPy server failed: " . $e->getMessage()); + } + } + + /** + * Downloads the given static file to the given path. + * + * In the case of non-public static files, access control must be done by the caller. + * + * @param string $namespace namespace of the package from which to retrieve the file + * @param string $shortname short name of the package from which to retrieve the file + * @param string $kind `static` for now + * @param string $path path of the static file in the package + * @param string $targetpath path where the file should be downloaded to. Anything here will be overwritten. + * @return string|null the mime type as reported by the server or null if the file wasn't found + * @throws coding_exception + */ + public function download_static_file(string $namespace, string $shortname, string $kind, string $path, + string $targetpath): ?string { + try { + $res = $this->guzzle_post_and_maybe_retry( + "/packages/$this->hash/file/$namespace/$shortname/$kind/$path", + ["sink" => $targetpath] + ); + } catch (BadResponseException $e) { + if ($e->getResponse()->getStatusCode() == 404) { + return null; + } + + throw new coding_exception( + "Request to '{$e->getRequest()->getUri()}' unexpectedly returned status code " . + "'{$e->getResponse()->getStatusCode()}'" + ); + } + + if ($res->hasHeader("Content-Type")) { + return $res->getHeader("Content-Type")[0]; + } else { + debugging("Server did not send Content-Type header, falling back to application/octet-stream"); + return "application/octet-stream"; + } + } + /** * Creates the multipart parts array. * @@ -181,6 +275,7 @@ private function create_request_parts(array $main, ?string $questionstate): arra * @param array $parts array of multipart parts * @return http_response_container * @throws moodle_exception + * @see guzzle_post_and_maybe_retry */ private function post_and_maybe_retry(string $subpath, array $parts): http_response_container { $connector = connector::default(); diff --git a/classes/api/qpy_http_client.php b/classes/api/qpy_http_client.php new file mode 100644 index 00000000..b5ac28fc --- /dev/null +++ b/classes/api/qpy_http_client.php @@ -0,0 +1,59 @@ +. + +namespace qtype_questionpy\api; + +use core\http_client; +use dml_exception; +use GuzzleHttp\HandlerStack; + +/** + * Guzzle http client configured with Moodle's standards ({@see http_client}) and QPy-specific ones. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qpy_http_client extends http_client { + /** + * Initializes a new client. + * + * @param array $config Guzzle config options. Mock handlers and history middleware can be added here, see + * {@link https://docs.guzzlephp.org/en/stable/testing.html#mock-handler}. + * @throws dml_exception + */ + public function __construct(array $config = []) { + $config["base_uri"] = rtrim(get_config('qtype_questionpy', 'server_url'), "/") . "/"; + $config["timeout"] = get_config('qtype_questionpy', 'server_timeout'); + parent::__construct($config); + } + + /** + * Get the handler stack according to the settings/options from client. + * + * @param array $settings The settings or options from client. + * @return HandlerStack + */ + protected function get_handlers(array $settings): HandlerStack { + $handlerstack = parent::get_handlers($settings); + /* This checks requests against Moodle's curlsecurityblockedhosts, which we don't want, since admins would need + to ensure their QPy server isn't in this list otherwise. There may be ways to granularly allow the + server_url, but this will do for now. */ + $handlerstack->remove("moodle_check_initial_request"); + return $handlerstack; + } +} diff --git a/classes/external/load_packages.php b/classes/external/load_packages.php index ae81fcd9..9c6fb1eb 100644 --- a/classes/external/load_packages.php +++ b/classes/external/load_packages.php @@ -21,6 +21,7 @@ global $CFG; require_once($CFG->libdir . "/externallib.php"); +use core\di; use external_api; use external_function_parameters; use external_single_structure; @@ -67,7 +68,7 @@ public static function execute(): array { } // Load and store packages from the application server. - $api = new api(); + $api = di::get(api::class); $packages = $api->get_packages(); foreach ($packages as $package) { $package->store(); diff --git a/classes/package_file_service.php b/classes/package_file_service.php index 9d477e7a..a160c40e 100644 --- a/classes/package_file_service.php +++ b/classes/package_file_service.php @@ -18,6 +18,7 @@ use coding_exception; use context_user; +use dml_exception; use stored_file; /** @@ -80,4 +81,28 @@ public function get_file_for_local_question(int $qpyid, int $contextid): stored_ } return reset($files); } + + /** + * If any question uses a manually uploaded package with the given hash, return the file. Otherwise, return null. + * + * @param string $packagehash + * @param int $contextid context id of the question, e.g. {@see \question_definition::$contextid} + * @return stored_file|null + * @throws dml_exception + * @throws coding_exception + */ + public function get_file_by_package_hash(string $packagehash, int $contextid): ?stored_file { + global $DB; + $qpyid = $DB->get_field("qtype_questionpy", "id", [ + "islocal" => true, + "pkgversionhash" => $packagehash, + ], IGNORE_MULTIPLE); + + if ($qpyid === false) { + // No question uses an uploaded (aka local) package with that hash. + return null; + } else { + return $this->get_file_for_local_question($qpyid, $contextid); + } + } } diff --git a/classes/question_ui_renderer.php b/classes/question_ui_renderer.php index 3ce99f1f..f1d8c799 100644 --- a/classes/question_ui_renderer.php +++ b/classes/question_ui_renderer.php @@ -25,6 +25,7 @@ use DOMProcessingInstruction; use DOMText; use DOMXPath; +use qtype_questionpy_question; use question_attempt; use question_display_options; @@ -63,7 +64,14 @@ class question_ui_renderer { * @param question_display_options $options * @param question_attempt $attempt */ - public function __construct(string $xml, array $placeholders, question_display_options $options, question_attempt $attempt) { + public function __construct(string $xml, array $placeholders, question_display_options $options, + question_attempt $attempt) { + $this->placeholders = $placeholders; + $this->options = $options; + $this->attempt = $attempt; + + $xml = $this->replace_qpy_urls($xml); + $this->xml = new DOMDocument(); $this->xml->preserveWhiteSpace = false; $this->xml->loadXML($xml); @@ -72,10 +80,6 @@ public function __construct(string $xml, array $placeholders, question_display_o $this->xpath = new DOMXPath($this->xml); $this->xpath->registerNamespace("xhtml", constants::NAMESPACE_XHTML); $this->xpath->registerNamespace("qpy", constants::NAMESPACE_QPY); - - $this->placeholders = $placeholders; - $this->options = $options; - $this->attempt = $attempt; } /** @@ -547,4 +551,34 @@ private function add_class_names(DOMElement $element, string ...$newclasses): vo $element->setAttribute("class", implode(" ", $classarray)); } + + /** + * Replaces QPy-URIs such as `qpy:acme/great_package/static/css/styles.css` with functioning pluginfile URLs. + * + * @param string $input + * @return string + */ + private function replace_qpy_urls(string $input): string { + $question = $this->attempt->get_question(); + assert($question instanceof qtype_questionpy_question); + + return preg_replace_callback( + // The first two path segments are namespace and short name, and so more restrictive. + ";qpy://static(/(?:[a-z_][a-z0-9_]{1,127}){2}(?:/[\w\-@:%+.~=]+)+);", + function (array $match) use ($question) { + $path = $match[1]; + $url = \moodle_url::make_pluginfile_url( + $question->contextid, + "qtype_questionpy", + "static", + null, + "/" . $question->packagehash . dirname($path) . "/", + basename($path) + ); + + return $url->out(); + }, + $input + ); + } } diff --git a/classes/static_file_service.php b/classes/static_file_service.php new file mode 100644 index 00000000..8472c4e8 --- /dev/null +++ b/classes/static_file_service.php @@ -0,0 +1,83 @@ +. + +namespace qtype_questionpy; + +use coding_exception; +use context_system; +use dml_exception; +use invalid_dataroot_permissions; +use qtype_questionpy\api\api; + +/** + * Handles retrieval, access control and caching of static package files. + * + * May also handle non-static attempt and scoring files files in the future, we'll see. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class static_file_service { + /** @var api */ + private readonly api $api; + + /** @var package_file_service */ + private readonly package_file_service $packagefileservice; + + /** + * Trivial constructor. + * @param api $api + * @param package_file_service $packagefileservice + */ + public function __construct(api $api, package_file_service $packagefileservice) { + $this->api = $api; + $this->packagefileservice = $packagefileservice; + } + + /** + * Gets and serves the given static file from the QPy server and dies afterwards. + * + * TODO: Cache the file. + * + * @param string $packagehash + * @param string $namespace + * @param string $shortname + * @param string $path + * @return array{ 0: string, 1: string }|null array of temporary file path and mime type or null of the file wasn't + * found + * @throws coding_exception + * @throws dml_exception + * @throws invalid_dataroot_permissions + */ + public function download_public_static_file(string $packagehash, string $namespace, string $shortname, string $path): ?array { + $path = ltrim($path, "/"); + $packagefileiflocal = $this->packagefileservice->get_file_by_package_hash($packagehash, context_system::instance()->id); + + $temppath = make_request_directory() . "/$packagehash/$namespace/$shortname/$path"; + make_writable_directory(dirname($temppath)); + + $mimetype = $this->api->package($packagehash, $packagefileiflocal) + ->download_static_file($namespace, $shortname, "static", $path, $temppath); + + if (is_null($mimetype)) { + return null; + } + + return [$temppath, $mimetype]; + } +} diff --git a/lib.php b/lib.php index d3ea2fab..ff53861f 100644 --- a/lib.php +++ b/lib.php @@ -22,10 +22,11 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\di; +use qtype_questionpy\static_file_service; + /** * Checks file access for QuestionPy questions. - * @package qtype_questionpy - * @category files * @param stdClass $course course object * @param stdClass $cm course module object * @param stdClass $context context object @@ -33,10 +34,36 @@ * @param array $args extra arguments * @param bool $forcedownload whether or not force download * @param array $options additional options affecting the file serving - * @return bool + * @throws moodle_exception + * @package qtype_questionpy + * @category files */ -function qtype_questionpy_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []) { +function qtype_questionpy_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []): never { global $CFG; require_once($CFG->libdir . '/questionlib.php'); - question_pluginfile($course, $context, 'qtype_questionpy', $filearea, $args, $forcedownload, $options); + + if ($filearea !== "static") { + // TODO: Support static-private files. + send_file_not_found(); + } + + $staticfileservice = di::get(static_file_service::class); + + [$packagehash, $namespace, $shortname] = $args; + $path = implode("/", array_slice($args, 3)); + + [$filepath, $mimetype] = $staticfileservice->download_public_static_file($packagehash, $namespace, $shortname, $path); + if (is_null($filepath)) { + send_file_not_found(); + } + + /* Set a lifetime of 1 year, i.e. effectively never expire. Since the package hash is part of the URL, cache busting + is automatic. */ + send_file( + $filepath, + basename($path), + lifetime: 31536000, + mimetype: $mimetype, + options: ["immutable" => true, "cacheability" => "public"] + ); } diff --git a/tests/question_ui_renderer_test.php b/tests/question_ui_renderer_test.php index ebe75644..2b1b6ae5 100644 --- a/tests/question_ui_renderer_test.php +++ b/tests/question_ui_renderer_test.php @@ -16,7 +16,16 @@ namespace qtype_questionpy; +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . "/question/type/questionpy/question.php"); + use coding_exception; +use PHPUnit\Framework\MockObject\Stub; +use qtype_questionpy\api\api; +use qtype_questionpy_question; +use question_attempt; /** * Unit tests for {@see question_ui_renderer}. @@ -60,9 +69,7 @@ private function assert_html_string_equals_html_string(string $expectedhtml, str public function test_should_hide_inline_feedback(): void { $input = file_get_contents(__DIR__ . "/question_uis/feedbacks.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $opts = new \question_display_options(); $opts->hide_all_feedback(); @@ -85,9 +92,7 @@ public function test_should_hide_inline_feedback(): void { public function test_should_show_inline_feedback(): void { $input = file_get_contents(__DIR__ . "/question_uis/feedbacks.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $opts = new \question_display_options(); $ui = new question_ui_renderer($input, [], $opts, $qa); @@ -111,13 +116,7 @@ public function test_should_show_inline_feedback(): void { public function test_should_mangle_names(): void { $input = file_get_contents(__DIR__ . "/question_uis/ids_and_names.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); - $qa->method("get_qt_field_name") - ->willReturnCallback(function ($name) { - return "mangled:$name"; - }); + $qa = $this->create_question_attempt_stub(); $ui = new question_ui_renderer($input, [], new \question_display_options(), $qa); $result = $ui->render(); @@ -154,9 +153,7 @@ public function test_should_mangle_names(): void { */ public function test_should_shuffle_the_same_way_in_same_attempt(): void { $input = file_get_contents(__DIR__ . "/question_uis/shuffle.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $firstresult = (new question_ui_renderer($input, [], new \question_display_options(), $qa))->render(); for ($i = 0; $i < 10; $i++) { @@ -174,9 +171,7 @@ public function test_should_shuffle_the_same_way_in_same_attempt(): void { */ public function test_should_resolve_placeholders(): void { $input = file_get_contents(__DIR__ . "/question_uis/placeholder.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $ui = new question_ui_renderer($input, [ "param" => "Value of param one.", @@ -205,9 +200,7 @@ public function test_should_resolve_placeholders(): void { */ public function test_should_remove_placeholders_when_no_corresponding_value(): void { $input = file_get_contents(__DIR__ . "/question_uis/placeholder.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $ui = new question_ui_renderer($input, [], new \question_display_options(), $qa); $result = $ui->render(); @@ -232,9 +225,7 @@ public function test_should_remove_placeholders_when_no_corresponding_value(): v */ public function test_should_soften_validations(): void { $input = file_get_contents(__DIR__ . "/question_uis/validations.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $ui = new question_ui_renderer($input, [], new \question_display_options(), $qa); $result = $ui->render(); @@ -262,9 +253,7 @@ public function test_should_soften_validations(): void { */ public function test_should_defuse_buttons(): void { $input = file_get_contents(__DIR__ . "/question_uis/buttons.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $ui = new question_ui_renderer($input, [], new \question_display_options(), $qa); $result = $ui->render(); @@ -290,9 +279,7 @@ public function test_should_defuse_buttons(): void { */ public function test_should_remove_element_with_if_role_attribute(): void { $input = file_get_contents(__DIR__ . "/question_uis/if-role.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $this->resetAfterTest(); $this->setGuestUser(); @@ -317,9 +304,7 @@ public function test_should_remove_element_with_if_role_attribute(): void { */ public function test_should_not_remove_element_with_if_role_attribute(): void { $input = file_get_contents(__DIR__ . "/question_uis/if-role.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $this->resetAfterTest(); $this->setAdminUser(); @@ -351,9 +336,7 @@ public function test_should_not_remove_element_with_if_role_attribute(): void { */ public function test_should_format_floats_in_en(): void { $input = file_get_contents(__DIR__ . "/question_uis/format-floats.xhtml"); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); + $qa = $this->create_question_attempt_stub(); $ui = new question_ui_renderer($input, [], new \question_display_options(), $qa); $result = $ui->render(); @@ -370,4 +353,49 @@ public function test_should_format_floats_in_en(): void { EXPECTED, $result); } + + /** + * Tests the replacement of QPy-URIs. + * + * @return void + * @throws coding_exception + * @covers \qtype_questionpy\question_ui_renderer::replace_qpy_urls + */ + public function test_should_replace_qpy_urls(): void { + $input = file_get_contents(__DIR__ . "/question_uis/qpy-urls.xhtml"); + $qa = $this->create_question_attempt_stub("deadbeef"); + + $ui = new question_ui_renderer($input, [], new \question_display_options(), $qa); + $result = $ui->render(); + + // phpcs:disable moodle.Files.LineLength.MaxExceeded + $this->assert_html_string_equals_html_string(<< + static link: https://www.example.com/moodle/pluginfile.php//qtype_questionpy/static/deadbeef/local/minimal_example/path1/path2/filename.txt + minimal path: https://www.example.com/moodle/pluginfile.php//qtype_questionpy/static/deadbeef/local/minimal_example/f + + EXPECTED, $result); + // phpcs:enable moodle.Files.LineLength.MaxExceeded + } + + /** + * Creates a stub question attempt which should fulfill the needs of most tests. + * + * @return question_attempt&Stub + */ + private function create_question_attempt_stub(?string $packagehash = null): question_attempt { + $packagehash ??= hash("sha256", random_string(64)); + $question = new qtype_questionpy_question($packagehash, "{}", null, $this->createStub(api::class)); + + $qa = $this->createStub(question_attempt::class); + $qa->method("get_database_id") + ->willReturn(mt_rand()); + $qa->method("get_question") + ->willReturn($question); + $qa->method("get_qt_field_name") + ->willReturnCallback(function ($name) { + return "mangled:$name"; + }); + return $qa; + } } diff --git a/tests/question_uis/qpy-urls.xhtml b/tests/question_uis/qpy-urls.xhtml new file mode 100644 index 00000000..2c982a07 --- /dev/null +++ b/tests/question_uis/qpy-urls.xhtml @@ -0,0 +1,21 @@ + + +
+ static link: qpy://static/local/minimal_example/path1/path2/filename.txt + minimal path: qpy://static/local/minimal_example/f +
diff --git a/tests/static_file_service_test.php b/tests/static_file_service_test.php new file mode 100644 index 00000000..6c16fcaf --- /dev/null +++ b/tests/static_file_service_test.php @@ -0,0 +1,160 @@ +. + +namespace qtype_questionpy; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . "/question/engine/tests/helpers.php"); +require_once(__DIR__ . "/data_provider.php"); + +use coding_exception; +use core\di; +use dml_exception; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; +use invalid_dataroot_permissions; +use moodle_exception; +use qtype_questionpy\api\api; +use qtype_questionpy\api\package_api; +use qtype_questionpy\api\qpy_http_client; + +/** + * Tests QuestionPy static file access. + * + * Ideally, we'd call {@see file_pluginfile} to test the entire path, but that isn't possible since Moodle expects + * our pluginfile function to die after serving and {@see send_file} isn't testable (It ends all levels of output + * buffering). + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \qtype_questionpy\static_file_service + * @covers \qtype_questionpy\api\package_api::download_static_file + */ +final class static_file_service_test extends \advanced_testcase { + /** @var MockHandler */ + private MockHandler $mockhandler; + + /** @var array */ + private array $requesthistory; + + /** @var static_file_service */ + private static_file_service $staticfileservice; + + /** + * Sets up mocks and the like before each test. + * + * @throws dml_exception + */ + protected function setUp(): void { + $this->mockhandler = new MockHandler(); + $this->requesthistory = []; + $handlerstack = HandlerStack::create($this->mockhandler); + $handlerstack->push(Middleware::history($this->requesthistory)); + $api = new api(new qpy_http_client([ + "handler" => $handlerstack, + ])); + di::set(api::class, $api); + + $packagefileservice = $this->createStub(package_file_service::class); + $packagefileservice + ->method("get_file_by_package_hash") + ->willReturn(null); + + $this->staticfileservice = new static_file_service($api, $packagefileservice); + } + + /** + * Tests the happy-path of a static file download. + * + * @return void + * @throws coding_exception + * @throws dml_exception + * @throws invalid_dataroot_permissions + */ + public function test_should_download_public_static_file(): void { + $hash = random_string(64); + $this->mockhandler->append(new Response(200, [ + "Content-Type" => "text/markdown", + ], "Static file content")); + + [$path, $mimetype] = $this->staticfileservice->download_public_static_file( + $hash, + "local", + "example", + "/path/to/file.txt" + ); + + $this->assertStringEqualsFile($path, "Static file content"); + $this->assertEquals("text/markdown", $mimetype); + + $this->assertCount(1, $this->requesthistory); + /** @var Request $req */ + $req = $this->requesthistory[0]["request"]; + $this->assertEquals("POST", $req->getMethod()); + $this->assertStringEndsWith("/packages/$hash/file/local/example/static/path/to/file.txt", $req->getUri()); + $this->assertEquals(0, $req->getBody()->getSize()); + } + + /** + * Tests that we fall back to `octet-stream` and emit a warning when the response includes no Content-Type. + * + * @return void + * @throws coding_exception + * @throws dml_exception + * @throws invalid_dataroot_permissions + */ + public function test_should_fall_back_and_warn_when_no_content_type(): void { + $this->mockhandler->append(new Response(200, [], "Static file content")); + + [, $mimetype] = $this->staticfileservice->download_public_static_file( + random_string(64), + "local", + "example", + "/path/to/file.txt" + ); + + $this->assertEquals("application/octet-stream", $mimetype); + $this->assertDebuggingCalled("Server did not send Content-Type header, falling back to application/octet-stream"); + } + + /** + * Tests that null is returned when the server responds with 404, indicating that the static file doesn't exist. + * + * @return void + * @throws dml_exception + * @throws moodle_exception + */ + public function test_should_return_null_when_file_doesnt_exist(): void { + $this->mockhandler->append(new Response(404, [])); + + $result = $this->staticfileservice->download_public_static_file( + random_string(64), + "local", + "example", + "/path/to/file.txt" + ); + + $this->assertNull($result); + } +}