diff --git a/public/lib/classes/output/requirements/import_map.php b/public/lib/classes/output/requirements/import_map.php new file mode 100644 index 00000000000..e8446ba9643 --- /dev/null +++ b/public/lib/classes/output/requirements/import_map.php @@ -0,0 +1,212 @@ +. + +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 + * @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 `/` + * 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 `/` subpath to an absolute filesystem path. + * + * For example, `mod_book/viewer` resolves to + * `/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; + } +} diff --git a/public/lib/classes/output/requirements/page_requirements_manager.php b/public/lib/classes/output/requirements/page_requirements_manager.php index 9607c4f503c..1a6437e6b54 100644 --- a/public/lib/classes/output/requirements/page_requirements_manager.php +++ b/public/lib/classes/output/requirements/page_requirements_manager.php @@ -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; diff --git a/public/lib/classes/param.php b/public/lib/classes/param.php index 96c6eaf9fe2..aae290c0123 100644 --- a/public/lib/classes/param.php +++ b/public/lib/classes/param.php @@ -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. * diff --git a/public/lib/classes/route/controller/esm_controller.php b/public/lib/classes/route/controller/esm_controller.php new file mode 100644 index 00000000000..ab0722499fb --- /dev/null +++ b/public/lib/classes/route/controller/esm_controller.php @@ -0,0 +1,133 @@ +. + +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 + * @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; + } +} diff --git a/public/lib/classes/router/middleware/moodle_bootstrap_middleware.php b/public/lib/classes/router/middleware/moodle_bootstrap_middleware.php index c6872d252bb..eb0d4fcf2a2 100644 --- a/public/lib/classes/router/middleware/moodle_bootstrap_middleware.php +++ b/public/lib/classes/router/middleware/moodle_bootstrap_middleware.php @@ -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); } diff --git a/public/lib/tests/output/requirements/import_map_test.php b/public/lib/tests/output/requirements/import_map_test.php new file mode 100644 index 00000000000..1b41d1aa8db --- /dev/null +++ b/public/lib/tests/output/requirements/import_map_test.php @@ -0,0 +1,103 @@ +. + +namespace core\output\requirements; + +/** + * Tests for the ESM import map class. + * + * @package core + * @category test + * @copyright 2026 Meirza + * @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']); + } +} diff --git a/public/lib/tests/param_test.php b/public/lib/tests/param_test.php index 933a64cc5fb..0617de7d7ae 100644 --- a/public/lib/tests/param_test.php +++ b/public/lib/tests/param_test.php @@ -22,16 +22,16 @@ namespace core; * @package core * @copyright Andrew Lyons * @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[] + */ + 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[] + */ + public static function valid_param_clean_invalid_provider(): array { + return [ + ['foo@', param::ESM_PATH], + ['@moodle/lms/@example', param::ESM_PATH], + ]; + } } diff --git a/public/lib/tests/route/controller/esm_controller_test.php b/public/lib/tests/route/controller/esm_controller_test.php new file mode 100644 index 00000000000..dc6c3cd9686 --- /dev/null +++ b/public/lib/tests/route/controller/esm_controller_test.php @@ -0,0 +1,161 @@ +. + +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 + * @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()); + } +}