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 @@
+
+
+
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);
+ }
+}