This commit is contained in:
Adrian Greeve
2026-03-06 09:14:05 +07:00
committed by Huong Nguyen
8 changed files with 780 additions and 44 deletions
@@ -0,0 +1,212 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\output\requirements;
/**
* The import map requirement class, which defines the import map for ES module loading.
*
* This class is responsible for defining the import map that will be used by the ES module loader to
* resolve module specifiers to URLs.
*
* A default loader URL should be set for the import map, which will be used for any specifiers
* that do not have a specific loader defined.
*
* The import map can be extended by adding additional imports with specific loaders, or overriding
* the standard loaders, during a pre_render hook.
*
* The import map will be serialized to JSON and included in the page output as a script tag with type "importmap".
*
* The class should be fetched using the dependency injection container, and the default loader URL
* should be set before the page is rendered.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class import_map implements \JsonSerializable {
/** @var array The list of imports */
protected array $imports = [];
/**
* @var bool Whether $imports has been sorted longest-key-first for prefix matching.
*
* The flag is reset to false whenever add_import() is called so the sort is re-applied
* if new entries are registered after the first resolution.
*/
private bool $importssorted = false;
/** @var \core\url The default loader URL to use */
protected \core\url $loader;
/**
* Initialise the import_map requirement by setting the standard import list.
*/
public function __construct() {
$this->add_standard_imports();
}
/**
* Prepare the content for json encoding.
*
* @return array[]|array{imports: array}
*/
public function jsonSerialize(): array {
$importmap = [
'imports' => [],
];
if (!isset($this->loader)) {
throw new \core\exception\coding_exception('Default loader URL must be set before serializing the import map.');
}
foreach ($this->imports as $specifier => $importdata) {
$loader = $importdata->loader instanceof \core\url
? $importdata->loader
: new \core\url($this->loader->out(false) . $specifier);
$importmap['imports'][$specifier] = $loader->out(false);
}
return $importmap;
}
/**
* Set the default loader URL.
*
* @param \core\url $loader The default loader URL.
*/
public function set_default_loader(\core\url $loader): void {
$this->loader = $loader;
}
/**
* Add the standard entries to the importmap.
* @return void
*/
protected function add_standard_imports(): void {
$this->add_import('@moodle/lms/', path: 'js/esm/build', loadfromcomponent: true);
$this->add_import('@moodlehq/design-system', path: 'lib/js/bundles/design-system');
$this->add_import('react', path: 'lib/js/bundles/react/react');
$this->add_import('react/', path: 'lib/js/bundles/react/');
$this->add_import('react-dom', path: 'lib/js/bundles/react-dom/react-dom');
}
/**
* Register a specifier in the import map.
*
* @param string $specifier The bare specifier used in import statements (e.g. `react`, `@moodle/lms/`).
* @param \core\url|null $loader Absolute URL written verbatim into the import map.
* When provided, $path is ignored for URL generation.
* @param string|null $path Filesystem path relative to $CFG->root, used by the ESM controller
* to locate the file on disk. Has no effect on the URL in the import map.
* @param bool $loadfromcomponent When true, the specifier is treated as a `<component>/<module>`
* prefix and resolved to the component's `js/esm/build/` directory. Used internally for `@moodle/lms/`.
*/
public function add_import(
string $specifier,
?\core\url $loader = null,
?string $path = null,
bool $loadfromcomponent = false,
): void {
$this->imports[$specifier] = (object) [
'loader' => $loader,
'path' => $path,
'loadfromcomponent' => $loadfromcomponent,
];
$this->importssorted = false;
}
/**
* Resolve a bare specifier path to an absolute filesystem path.
*
* Entries are matched longest-key-first so a more-specific prefix always wins
* (e.g. `react/` is matched before `react`). Returns null if no entry matches.
*
* @param string $requestedpath The bare specifier path (e.g. `react`, `@moodle/lms/mod_book/viewer`).
* @return string|null Absolute filesystem path to the JS file, or null if unresolved.
*/
public function get_path_for_script(string $requestedpath): ?string {
global $CFG;
// Sort longest-key-first once so a more-specific prefix always wins over a shorter one.
if (!$this->importssorted) {
uksort($this->imports, fn ($a, $b) => strlen($b) <=> strlen($a));
$this->importssorted = true;
}
foreach ($this->imports as $specifier => $importdata) {
if (!str_starts_with($requestedpath, $specifier)) {
continue;
}
if ($importdata->loader !== null) {
throw new \core\exception\coding_exception(
'Import map entries with explicit loaders cannot be resolved to filesystem paths.',
);
}
if ($importdata->loadfromcomponent) {
$subpath = substr($requestedpath, strlen($specifier));
return $this->resolve_module_identifier($importdata, $subpath);
}
$pathremainder = substr($requestedpath, strlen($specifier));
// Reject '..' as a path segment to prevent directory traversal. A single dot in a
// filename (e.g. 'button.small') is allowed because it is not a segment on its own.
if (in_array('..', explode('/', $pathremainder), true)) {
return null;
}
return implode(DIRECTORY_SEPARATOR, array_filter([$CFG->root, $importdata->path, $pathremainder])) . '.js';
}
return null;
}
/**
* Resolve a `<component>/<module>` subpath to an absolute filesystem path.
*
* For example, `mod_book/viewer` resolves to
* `<dirroot>/mod/book/js/esm/build/viewer.js`.
*
* @param object $importdata The import entry containing the path and loadfromcomponent flag.
* @param string $subpath The subpath after the specifier prefix (e.g. `mod_book/viewer`).
* @return string Absolute path to the JS file.
* @throws \core\exception\not_found_exception If the subpath is missing a slash, contains `..`,
* the component is unknown, or the resolved file does not exist.
*/
protected function resolve_module_identifier(object $importdata, string $subpath): string {
if (!str_contains($subpath, '/')) {
throw new \core\exception\not_found_exception('component', $subpath);
}
[$component, $modulerest] = explode('/', $subpath, 2);
// Reject '..' as a path segment to prevent directory traversal. A single dot in a
// filename (e.g. 'button.small') is allowed because it is not a segment on its own.
if (in_array('..', explode('/', $modulerest), true)) {
throw new \core\exception\not_found_exception('script', $subpath);
}
// Resolve the component directory; an unknown component name returns null.
$dir = \core\component::get_component_directory($component);
$file = "{$dir}/{$importdata->path}/{$modulerest}.js";
if (!file_exists($file)) {
throw new \core\exception\not_found_exception('script', $subpath);
}
return $file;
}
}
@@ -1040,6 +1040,33 @@ class page_requirements_manager {
$this->skiplinks[$target] = $linktext;
}
/**
* Returns the import map script tag for React platform files.
*
* @return string
*/
public function get_import_map(): string {
$importmap = \core\di::get(import_map::class);
$importmap->set_default_loader(
\core\router\util::get_path_for_callable(
[\core\route\controller\esm_controller::class, 'serve'],
[
'revision' => $this->get_jsrev(),
'scriptpath' => '',
]
),
);
return html_writer::tag(
'script',
json_encode(
$importmap,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
),
['type' => 'importmap'],
);
}
/**
* !!!DEPRECATED!!! please use js_init_call() if possible
* Ensure that the specified JavaScript function is called from an inline script
@@ -1703,6 +1730,9 @@ EOF;
$output .= html_writer::script($js);
}
// Inject the ES module import map for bare specifier resolution.
$output .= $this->get_import_map();
// Mark head sending done, it is not possible to anything there.
$this->headdone = true;
+34
View File
@@ -343,6 +343,16 @@ enum param: string {
#[param_clientside_regex('^[a-z](?:[a-z0-9_](?!__))*[a-z0-9]+$')]
case PLUGIN = 'plugin';
/**
* PARAM_ESM_PATH is used for ES module script paths as they arrive at the ESM controller,
* such as 'react', '@moodle/lms/mod_forum/index', 'react/jsx-runtime', or '@moodlehq/design-system/button.small'.
* Accepts lowercase letters, numbers, hyphens, underscores, dots, forward slashes, and a leading @.
* The path must start with a letter or @. Directory traversal via '..' is rejected by the ESM controller.
*/
#[param_clientside_regex('^[@a-z][a-z0-9_.-]*(/[a-z0-9_./-]+)?$')]
case ESM_PATH = 'esm_path';
/**
* Get the canonical enumerated parameter from the parameter type name.
*
@@ -1336,6 +1346,30 @@ enum param: string {
}
}
/**
* Clean an ESM script path value.
*
* Accepts only paths that match the ESM_PATH regex (lowercase letters, digits,
* hyphens, underscores, dots, and forward slashes, starting with a letter or @).
* Returns an empty string if the value does not match.
*
* Note: directory traversal via '..' segments is enforced by the ESM controller,
* not by this method.
*
* @param mixed $param The raw parameter value to clean.
* @return string The cleaned path, or an empty string if invalid.
*/
protected function clean_param_value_esm_path(mixed $param): string {
// ESM paths must be relative and contain only safe characters.
$param = (string)fix_utf8($param);
$regex = $this->get_clientside_expression();
if (preg_match("~{$regex}~", $param)) {
return $param;
} else {
return '';
}
}
/**
* Whether the parameter is deprecated.
*
@@ -0,0 +1,133 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\route\controller;
use core\router\schema\parameters\path_parameter;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Controller for serving ES module files.
*
* Resolves all ESM requests under /esm/{revision}/{scriptpath} by delegating to the
* import_map registry, which is the single source of truth for specifier → file mappings.
*
* @package core
* @copyright 2026 Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class esm_controller {
use \core\router\route_controller;
#[\core\router\route(
title: 'Serve ESM Content',
path: '/esm/{revision:[0-9-]+}/{scriptpath:.*}',
pathtypes: [
new path_parameter(
name: 'revision',
description: 'The revision number of the script to serve.',
type: \core\param::INT,
),
new path_parameter(
name: 'scriptpath',
description: 'The path to the script to serve.',
type: \core\param::ESM_PATH,
),
],
method: ['GET'],
abortafterconfig: true,
)]
/**
* Serve an ES module file by resolving the specifier via the import map.
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param int $revision
* @param string $scriptpath
*/
public function serve(
ServerRequestInterface $request,
ResponseInterface $response,
int $revision,
string $scriptpath,
): ResponseInterface {
// Normalise the revision: an outdated or invalid value disables long-term caching
// so browsers always re-fetch rather than serving a stale file.
if (!min_is_revision_valid_and_current($revision)) {
$revision = -1;
}
$importmap = \core\di::get(\core\output\requirements\import_map::class);
$fullpath = $importmap->get_path_for_script($scriptpath);
if ($fullpath !== null && file_exists($fullpath)) {
return $this->serve_script($request, $response, $revision, $fullpath, basename($fullpath));
}
throw new \core\exception\not_found_exception('script', $scriptpath);
}
/**
* Write the file content into the response with appropriate cache headers.
*
* When $revision is -1 (invalid/development), short-lived cache headers are used.
* Otherwise, immutable long-lived cache headers are set with an ETag, and a
* 304 Not Modified response is returned if the client already has the file cached.
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param int $revision The JS revision number; -1 disables long-term caching.
* @param string $file Absolute filesystem path to the JS file.
* @param string $presentedfilename Filename to use in Content-Disposition.
*/
protected function serve_script(
ServerRequestInterface $request,
ResponseInterface $response,
int $revision,
string $file,
string $presentedfilename,
): ResponseInterface {
$now = \core\di::get(\core\clock::class)->time();
if ($revision === -1) {
$response = $response
->withHeader('Content-Type', 'application/javascript; charset=utf-8')
->withHeader('Content-Disposition', "inline; filename=\"{$presentedfilename}\"")
->withHeader('Last-Modified', gmdate('D, d M Y H:i:s', $now) . ' GMT')
->withHeader('Expires', gmdate('D, d M Y H:i:s', $now + 2) . ' GMT')
->withHeader('Pragma', '')
->withHeader('Accept-Ranges', 'none');
} else {
$etag = sha1($revision . ':' . $file);
if ($request->hasHeader('If-None-Match') && in_array($etag, $request->getHeader('If-None-Match'))) {
return $response->withStatus(304);
}
$response = $response
->withHeader('Content-Type', 'application/javascript; charset=utf-8')
->withHeader('ETag', $etag)
->withHeader('Content-Disposition', 'inline; filename="' . basename($file) . '"')
->withHeader('Last-Modified', gmdate('D, d M Y H:i:s', filemtime($file)) . ' GMT')
->withHeader('Expires', gmdate('D, d M Y H:i:s', $now + 31536000) . ' GMT')
->withHeader('Pragma', '')
->withHeader('Cache-Control', 'public, max-age=31536000, immutable')
->withHeader('Accept-Ranges', 'none');
}
$response->getBody()->write(file_get_contents($file));
return $response;
}
}
@@ -58,53 +58,55 @@ class moodle_bootstrap_middleware implements MiddlewareInterface {
$this->load_full_moodle();
}
// Set the URL for the page.
// Normally in Moodle this is a largely hard-coded value with only the query string changing dynamically in the page.
// However, in this instance, we are generating the URL dynamically because we are the request terminator
// for a large number of requests at different endpoints.
if ($PAGE) {
// Set the URL for the page.
// Normally in Moodle this is a largely hard-coded value with only the query string changing dynamically in the page.
// However, in this instance, we are generating the URL dynamically because we are the request terminator
// for a large number of requests at different endpoints.
// In basic cases this will just work, but there are some edge cases to consider - specficially were the site
// is behind a reverse proxy and/or an SSL terminator.
// In these cases the URL we generate from the ServerRequestInterface may be the _terminated_ URL,
// and not the URL that was requested by the client.
// In basic cases this will just work, but there are some edge cases to consider - specficially were the site
// is behind a reverse proxy and/or an SSL terminator.
// In these cases the URL we generate from the ServerRequestInterface may be the _terminated_ URL,
// and not the URL that was requested by the client.
// We need to generate the URL that the client requested, not the URL that the server received.
$url = $request->getUri();
// We need to generate the URL that the client requested, not the URL that the server received.
$url = $request->getUri();
if (!empty($CFG->reverseproxy)) {
// This site is behind a reverse proxy. The requested URI may have a different:
// - scheme
// - host
// - port
// to the URL that the client requested.
if (!empty($CFG->reverseproxy)) {
// This site is behind a reverse proxy. The requested URI may have a different:
// - scheme
// - host
// - port
// to the URL that the client requested.
$url = $url
// Start by setting the scheme and host to the wwwroot.
->withScheme(parse_url($CFG->wwwroot, PHP_URL_SCHEME))
->withHost(parse_url($CFG->wwwroot, PHP_URL_HOST))
$url = $url
// Start by setting the scheme and host to the wwwroot.
->withScheme(parse_url($CFG->wwwroot, PHP_URL_SCHEME))
->withHost(parse_url($CFG->wwwroot, PHP_URL_HOST))
// Update the URL to match the port of the wwwroot.
// While it is highly unlikely that a wwwroot includes an explicit port, we should still handle it.
->withPort(parse_url($CFG->wwwroot, PHP_URL_PORT));
// Update the URL to match the port of the wwwroot.
// While it is highly unlikely that a wwwroot includes an explicit port, we should still handle it.
->withPort(parse_url($CFG->wwwroot, PHP_URL_PORT));
}
if (!empty($CFG->sslproxy)) {
// This site is behind an ssl terminating proxy. The requested URI may have a different:
// - scheme
// - port
// to the URL that the client requested.
$url = $url
// The wwwroot must use the https scheme, but the terminating request may have been received using http.
->withScheme('https')
// Update the URL to match the port of the wwwroot.
// While it is highly unlikely that a wwwroot includes an explicit port, we should still handle it.
->withPort(parse_url($CFG->wwwroot, PHP_URL_PORT));
}
$PAGE->set_url((string) $url);
}
if (!empty($CFG->sslproxy)) {
// This site is behind an ssl terminating proxy. The requested URI may have a different:
// - scheme
// - port
// to the URL that the client requested.
$url = $url
// The wwwroot must use the https scheme, but the terminating request may have been received using http.
->withScheme('https')
// Update the URL to match the port of the wwwroot.
// While it is highly unlikely that a wwwroot includes an explicit port, we should still handle it.
->withPort(parse_url($CFG->wwwroot, PHP_URL_PORT));
}
$PAGE->set_url((string) $url);
return $handler->handle($request);
}
@@ -0,0 +1,103 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\output\requirements;
/**
* Tests for the ESM import map class.
*
* @package core
* @category test
* @copyright 2026 Meirza <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[\PHPUnit\Framework\Attributes\CoversClass(import_map::class)]
final class import_map_test extends \advanced_testcase {
/**
* The constructor pre-populates the standard ESM specifiers.
*/
public function test_constructor_adds_standard_imports(): void {
$map = new import_map();
$map->set_default_loader(new \core\url('https://example.com/'));
$data = $map->jsonSerialize();
$this->assertArrayHasKey('@moodle/lms/', $data['imports']);
$this->assertArrayHasKey('react', $data['imports']);
$this->assertArrayHasKey('react/', $data['imports']);
}
/**
* jsonSerialize() throws a coding_exception when no default loader has been set.
*/
public function test_jsonserialize_throws_without_loader(): void {
$this->expectException(\core\exception\coding_exception::class);
(new import_map())->jsonSerialize();
}
/**
* jsonSerialize() returns an array with an 'imports' key.
*/
public function test_jsonserialize_returns_imports_structure(): void {
$map = new import_map();
$map->set_default_loader(new \core\url('https://example.com/'));
$data = $map->jsonSerialize();
$this->assertIsArray($data);
$this->assertArrayHasKey('imports', $data);
$this->assertIsArray($data['imports']);
}
/**
* set_default_loader() is used as the base URL when no explicit loader or path is given.
*/
public function test_set_default_loader_is_used_as_base_url(): void {
$map = new import_map();
$map->set_default_loader(new \core\url('https://example.com/esm/12345/'));
$map->add_import('my-module');
$data = $map->jsonSerialize();
$this->assertEquals('https://example.com/esm/12345/my-module', $data['imports']['my-module']);
}
/**
* add_import() with an explicit \core\url uses that URL verbatim, ignoring the default loader.
*/
public function test_add_import_with_explicit_url(): void {
$map = new import_map();
$map->set_default_loader(new \core\url('https://example.com/'));
$map->add_import('my-module', loader: new \core\url('https://cdn.example.com/my-module.js'));
$data = $map->jsonSerialize();
$this->assertEquals('https://cdn.example.com/my-module.js', $data['imports']['my-module']);
}
/**
* add_import() with no $path and no explicit $loader appends the specifier to the loader URL.
*/
public function test_add_import_without_path_uses_specifier(): void {
$map = new import_map();
$map->set_default_loader(new \core\url('https://example.com/esm/12345/'));
$map->add_import('some/specifier');
$data = $map->jsonSerialize();
$this->assertEquals('https://example.com/esm/12345/some/specifier', $data['imports']['some/specifier']);
}
}
+66 -5
View File
@@ -22,16 +22,16 @@ namespace core;
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core\param
*/
#[\PHPUnit\Framework\Attributes\CoversClass(param::class)]
final class param_test extends \advanced_testcase {
/**
* Test that the Moodle `from_type` method provides canonicalised parameter values.
*
* @dataProvider valid_param_provider
* @param string $type
* @param param $expected
*/
#[\PHPUnit\Framework\Attributes\DataProvider('valid_param_provider')]
public function test_from_type(string $type, param $expected): void {
$this->assertEquals($expected, param::from_type($type));
}
@@ -71,10 +71,10 @@ final class param_test extends \advanced_testcase {
/**
* Test that deprecated parameters are marked as such.
*
* @dataProvider is_deprecated_provider
* @param param $param
* @param bool $expected
*/
#[\PHPUnit\Framework\Attributes\DataProvider('is_deprecated_provider')]
public function test_is_deprecated(param $param, bool $expected): void {
$this->assertEquals(
$expected,
@@ -105,9 +105,9 @@ final class param_test extends \advanced_testcase {
/**
* Test that finally deprecated params throw an exception when cleaning.
*
* @dataProvider deprecated_param_provider
* @param param $params
* @param param $param
*/
#[\PHPUnit\Framework\Attributes\DataProvider('deprecated_param_provider')]
public function test_deprecated_params_except(param $param): void {
$this->expectException(\coding_exception::class);
$param->clean('foo');
@@ -132,4 +132,65 @@ final class param_test extends \advanced_testcase {
),
);
}
/**
* Test that valid parameters clean correctly (i.e. return the input value).
*
* @param string $input
* @param \core\param $param
*/
#[\PHPUnit\Framework\Attributes\DataProvider('valid_param_clean_provider')]
public function test_param_clean(
string $input,
\core\param $param,
): void {
$cleaned = $param->clean($input);
$this->assertEquals($input, $cleaned);
}
/**
* Data provider for the valid provider for param::clean.
*
* @return array<param|string>[]
*/
public static function valid_param_clean_provider(): array {
return [
['1', param::INT],
['1.5', param::FLOAT],
['foo', param::ESM_PATH],
['foo/bar', param::ESM_PATH],
['foo/bar/baz', param::ESM_PATH],
['@moodle/lms/example', param::ESM_PATH],
['react/jsx-runtime', param::ESM_PATH],
['button.small', param::ESM_PATH],
['@moodlehq/design-system/button.small', param::ESM_PATH],
];
}
/**
* Test that valid parameters clean correctly (i.e. return the input value).
*
* @param string $input
* @param \core\param $param
*/
#[\PHPUnit\Framework\Attributes\DataProvider('valid_param_clean_invalid_provider')]
public function test_param_clean_invalid(
string $input,
\core\param $param,
): void {
$cleaned = $param->clean($input);
$this->assertEquals('', $cleaned);
}
/**
* Data provider for the invalid provider for param::clean.
*
* @return array<param|string>[]
*/
public static function valid_param_clean_invalid_provider(): array {
return [
['foo@', param::ESM_PATH],
['@moodle/lms/@example', param::ESM_PATH],
];
}
}
@@ -0,0 +1,161 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\route\controller;
use core\tests\router\route_testcase;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest;
/**
* Tests for the ESM controller shim route.
*
* @package core
* @category test
* @copyright 2026 Meirza <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[\PHPUnit\Framework\Attributes\CoversClass(esm_controller::class)]
final class esm_controller_test extends route_testcase {
#[\PHPUnit\Framework\Attributes\After]
public function reset(): void {
\core\di::reset_container();
}
/**
* Inject a stub import_map into the DI container that always returns a given fixture file,
* then return a plain esm_controller that will use it.
*
* @return esm_controller
*/
private function make_test_controller(?string $filename = null): esm_controller {
if ($filename === null) {
$filename = make_request_directory() . '/test.js';
}
file_put_contents($filename, 'export default {};');
\core\di::set(
\core\output\requirements\import_map::class,
new class ($filename) extends \core\output\requirements\import_map {
// phpcs:ignore
public function __construct(private readonly string $fixture) {}
// phpcs:ignore
public function get_path_for_script(string $requestedpath): ?string {
return $this->fixture;
}
},
);
return new esm_controller();
}
/**
* Data provider for serve() not-found cases.
*
* @return array
*/
public static function serve_not_found_provider(): array {
return [
'unmatched specifier' => ['some-unknown-lib'],
'matched prefix, missing module file' => ['@moodle/lms/core/nonexistentmodule'],
];
}
/**
* serve() throws not_found_exception for paths that cannot be resolved to a file.
*
* @param string $scriptpath
*/
#[\PHPUnit\Framework\Attributes\DataProvider('serve_not_found_provider')]
public function test_serve_not_found(string $scriptpath): void {
$this->expectException(\core\exception\not_found_exception::class);
(new esm_controller())->serve(
new ServerRequest('GET', "/12345/{$scriptpath}"),
new Response(),
12345,
$scriptpath,
);
}
/**
* A valid component module returns 200 with an application/javascript Content-Type.
*/
public function test_serve_component_module_returns_javascript(): void {
$response = $this->make_test_controller()->serve(
new ServerRequest('GET', '/-1/mod_test/index'),
new Response(),
-1,
'mod_test/index',
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertStringContainsString('application/javascript', $response->getHeaderLine('Content-Type'));
}
/**
* An invalid revision (1) results in a short-lived cache response with no ETag.
*/
public function test_serve_invalid_revision_uses_short_cache(): void {
$response = $this->make_test_controller()->serve(
new ServerRequest('GET', '/-1/mod_test/index'),
new Response(),
-1,
'mod_test/index',
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertFalse($response->hasHeader('ETag'), 'Short-cache responses must not include an ETag.');
$this->assertFalse($response->hasHeader('Cache-Control'), 'Short-cache responses must not include Cache-Control.');
}
/**
* A valid revision results in a long-lived immutable cache response with an ETag.
*/
public function test_serve_valid_revision_sets_long_cache(): void {
$clock = $this->mock_clock_with_frozen();
$revision = $clock->time();
$response = $this->make_test_controller()->serve(
new ServerRequest('GET', "/{$revision}/mod_test/index"),
new Response(),
$revision,
'mod_test/index',
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertTrue($response->hasHeader('ETag'), 'Long-cache responses must include an ETag.');
$this->assertStringContainsString('immutable', $response->getHeaderLine('Cache-Control'));
}
/**
* When the request carries an If-None-Match header that matches the ETag, serve() returns 304.
*/
public function test_serve_matching_etag_returns_304(): void {
$filename = make_request_directory() . '/test.js';
$controller = $this->make_test_controller($filename);
$clock = $this->mock_clock_with_frozen();
$revision = $clock->time();
// Create the file content.
$etag = sha1("{$revision}:{$filename}");
$request = (new ServerRequest('GET', "/{$revision}/mod_test/index"))
->withHeader('If-None-Match', $etag);
$response = $controller->serve($request, new Response(), $revision, 'mod_test/index');
$this->assertEquals(304, $response->getStatusCode());
}
}