diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5e304..0422852 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: env: BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails-version }}_${{ matrix.assets-pipeline }}.gemfile + ASSETS_PIPELINE: ${{ matrix.assets-pipeline }} steps: - uses: actions/checkout@v4 diff --git a/Appraisals b/Appraisals index 6843f03..e2be1b6 100644 --- a/Appraisals +++ b/Appraisals @@ -17,7 +17,6 @@ end appraise "rails_7.0_propshaft" do gem "rails", github: "rails/rails", branch: "7-0-stable" - gem "propshaft" gem "sqlite3", "~> 1.4" end @@ -29,7 +28,6 @@ end appraise "rails_7.1_propshaft" do gem "rails", "~> 7.1.0" - gem "propshaft" end appraise "rails_7.2_sprockets" do @@ -40,7 +38,6 @@ end appraise "rails_7.2_propshaft" do gem "rails", "~> 7.2.0" - gem "propshaft" end appraise "rails_8.0_sprockets" do @@ -51,7 +48,6 @@ end appraise "rails_8.0_propshaft" do gem "rails", "~> 8.0.0" - gem "propshaft" end appraise "rails_main_sprockets" do @@ -62,5 +58,4 @@ end appraise "rails_main_propshaft" do gem "rails", github: "rails/rails", branch: "main" - gem "propshaft" end diff --git a/Gemfile b/Gemfile index a144c5e..cdd020f 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } gemspec gem "rails" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" diff --git a/Gemfile.lock b/Gemfile.lock index 09d05d7..150f47f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,13 +105,13 @@ GEM crass (1.0.6) date (3.4.1) drb (2.2.3) - erb (5.0.1) + erb (5.0.2) erubi (1.13.1) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.7) concurrent-ruby (~> 1.0) - io-console (0.8.0) + io-console (0.8.1) irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) @@ -150,11 +150,10 @@ GEM pp (0.6.2) prettyprint prettyprint (0.2.0) - propshaft (1.1.0) + propshaft (1.2.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) psych (5.2.6) date stringio @@ -257,7 +256,7 @@ DEPENDENCIES byebug capybara importmap-rails! - propshaft + propshaft (>= 1.2.0) rails rexml selenium-webdriver diff --git a/README.md b/README.md index 30e10bf..bc2b2b2 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ import React from "./node_modules/react" import React from "https://ga.jspm.io/npm:react@17.0.1/index.js" ``` -Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"` +Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"` to 1 of the 3 viable ways of loading ES Module javascript packages. For example: @@ -54,11 +54,11 @@ For example: pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js" ``` -means "everytime you see `import React from "react"` +means "every time you see `import React from "react"` change it to `import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"`" ```js -import React from "react" +import React from "react" // => import React from "https://ga.jspm.io/npm:react@17.0.2/index.js" ``` @@ -79,10 +79,15 @@ If you want to import local js module files from `app/javascript/src` or other s ```rb # config/importmap.rb pin_all_from 'app/javascript/src', under: 'src', to: 'src' + +# With automatic integrity calculation for enhanced security +pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: true ``` The `:to` parameter is only required if you want to change the destination logical import name. If you drop the :to option, you must place the :under option directly after the first parameter. +The `integrity: true` option automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management. + Allows you to: ```js @@ -131,6 +136,137 @@ If you later wish to remove a downloaded pin: Unpinning and removing "react" ``` +## Subresource Integrity (SRI) + +For enhanced security, importmap-rails automatically includes [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes by default when pinning packages. This ensures that JavaScript files loaded from CDNs haven't been tampered with. + +### Default behavior with integrity + +When you pin a package, integrity hashes are automatically included: + +```bash +./bin/importmap pin lodash +Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:lodash@4.17.21/lodash.js + Using integrity: sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF +``` + +This generates a pin in your `config/importmap.rb` with the integrity hash: + +```ruby +pin "lodash", integrity: "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" # @4.17.21 +``` + +### Opting out of integrity + +If you need to disable integrity checking (not recommended for security reasons), you can use the `--no-integrity` flag: + +```bash +./bin/importmap pin lodash --no-integrity +Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:lodash@4.17.21/lodash.js +``` + +This generates a pin without integrity: + +```ruby +pin "lodash" # @4.17.21 +``` + +### Adding integrity to existing pins + +If you have existing pins without integrity hashes, you can add them using the `integrity` command: + +```bash +# Add integrity to specific packages +./bin/importmap integrity lodash react + +# Add integrity to all pinned packages +./bin/importmap integrity + +# Update your importmap.rb file with integrity hashes +./bin/importmap integrity --update +``` + +### Automatic integrity for local assets + +For local assets served by the Rails asset pipeline (like those created with `pin` or `pin_all_from`), you can use `integrity: true` to automatically calculate integrity hashes from the compiled assets: + +```ruby +# config/importmap.rb + +# Automatically calculate integrity from asset pipeline +pin "application", integrity: true +pin "admin", to: "admin.js", integrity: true + +# Works with pin_all_from too +pin_all_from "app/javascript/controllers", under: "controllers", integrity: true +pin_all_from "app/javascript/lib", under: "lib", integrity: true + +# Mixed usage +pin "local_module", integrity: true # Auto-calculated +pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated +pin "no_integrity_package" # No integrity (default) +``` + +This is particularly useful for: +* **Local JavaScript files** managed by your Rails asset pipeline +* **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious +* **Development workflow** where asset contents change frequently + +The `integrity: true` option: +* Uses the Rails asset pipeline's built-in integrity calculation +* Works with both Sprockets and Propshaft +* Automatically updates when assets are recompiled +* Gracefully handles missing assets (returns `nil` for non-existent files) + +**Example output with `integrity: true`:** +```json +{ + "imports": { + "application": "/assets/application-abc123.js", + "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js" + }, + "integrity": { + "/assets/application-abc123.js": "sha256-xyz789...", + "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..." + } +} +``` + +### How integrity works + +The integrity hashes are automatically included in your import map and module preload tags: + +**Import map JSON:** +```json +{ + "imports": { + "lodash": "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js" + }, + "integrity": { + "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" + } +} +``` + +**Module preload tags:** +```html + +``` + +Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified. + +### Redownloading packages with integrity + +The `pristine` command also includes integrity by default: + +```bash +# Redownload all packages with integrity (default) +./bin/importmap pristine + +# Redownload packages without integrity +./bin/importmap pristine --no-integrity +``` + ## Preloading pinned modules To avoid the waterfall effect where the browser has to load one file after another before it can get to the deepest nested import, importmap-rails uses [modulepreload links](https://developers.google.com/web/updates/2017/12/modulepreload) by default. If you don't want to preload a dependency, because you want to load it on-demand for efficiency, append `preload: false` to the pin. @@ -217,7 +353,7 @@ Pin your js file: pin "checkout", preload: false ``` -Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specifc page/partial, then yield it in your layout. +Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specific page/partial, then yield it in your layout. ```erb <% content_for :head do %> diff --git a/app/helpers/importmap/importmap_tags_helper.rb b/app/helpers/importmap/importmap_tags_helper.rb index fee48a3..295b6dc 100644 --- a/app/helpers/importmap/importmap_tags_helper.rb +++ b/app/helpers/importmap/importmap_tags_helper.rb @@ -25,13 +25,23 @@ def javascript_import_module_tag(*module_names) # (defaults to Rails.application.importmap), such that they'll be fetched # in advance by browsers supporting this link type (https://caniuse.com/?search=modulepreload). def javascript_importmap_module_preload_tags(importmap = Rails.application.importmap, entry_point: "application") - javascript_module_preload_tag(*importmap.preloaded_module_paths(resolver: self, entry_point:, cache_key: entry_point)) + packages = importmap.preloaded_module_packages(resolver: self, entry_point:, cache_key: entry_point) + + _generate_preload_tags(packages) { |path, package| [path, { integrity: package.integrity }] } end # Link tag(s) for preloading the JavaScript module residing in `*paths`. Will return one link tag per path element. def javascript_module_preload_tag(*paths) - safe_join(Array(paths).collect { |path| - tag.link rel: "modulepreload", href: path, nonce: request&.content_security_policy_nonce - }, "\n") + _generate_preload_tags(paths) { |path| [path, {}] } end + + private + def _generate_preload_tags(items) + content_security_policy_nonce = request&.content_security_policy_nonce + + safe_join(Array(items).collect { |item| + path, options = yield(item) + tag.link rel: "modulepreload", href: path, nonce: content_security_policy_nonce, **options + }, "\n") + end end diff --git a/gemfiles/rails_7.0_propshaft.gemfile b/gemfiles/rails_7.0_propshaft.gemfile index 6096f3a..896a5b6 100644 --- a/gemfiles/rails_7.0_propshaft.gemfile +++ b/gemfiles/rails_7.0_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", branch: "7-0-stable", git: "https://github.com/rails/rails.git" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3", "~> 1.4" group :development do diff --git a/gemfiles/rails_7.1_propshaft.gemfile b/gemfiles/rails_7.1_propshaft.gemfile index df7a907..e695ca4 100644 --- a/gemfiles/rails_7.1_propshaft.gemfile +++ b/gemfiles/rails_7.1_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", "~> 7.1.0" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" group :development do diff --git a/gemfiles/rails_7.2_propshaft.gemfile b/gemfiles/rails_7.2_propshaft.gemfile index c081234..dff1f0c 100644 --- a/gemfiles/rails_7.2_propshaft.gemfile +++ b/gemfiles/rails_7.2_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", "~> 7.2.0" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" group :development do diff --git a/gemfiles/rails_8.0_propshaft.gemfile b/gemfiles/rails_8.0_propshaft.gemfile index 034cb71..c6acfd3 100644 --- a/gemfiles/rails_8.0_propshaft.gemfile +++ b/gemfiles/rails_8.0_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", "~> 8.0.0" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" group :development do diff --git a/gemfiles/rails_main_propshaft.gemfile b/gemfiles/rails_main_propshaft.gemfile index 3de5eec..a29a396 100644 --- a/gemfiles/rails_main_propshaft.gemfile +++ b/gemfiles/rails_main_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", branch: "main", git: "https://github.com/rails/rails.git" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" group :development do diff --git a/lib/importmap/commands.rb b/lib/importmap/commands.rb index d3fda40..3b5fd55 100644 --- a/lib/importmap/commands.rb +++ b/lib/importmap/commands.rb @@ -13,21 +13,19 @@ def self.exit_on_failure? option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" option :preload, type: :string, repeatable: true, desc: "Can be used multiple times" + option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM" def pin(*packages) - if imports = packager.import(*packages, env: options[:env], from: options[:from]) - imports.each do |package, url| + with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes| + process_imports(imports, integrity_hashes) do |package, url, integrity_hash| puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}) + packager.download(package, url) - pin = packager.vendored_pin_for(package, url, options[:preload]) - if packager.packaged?(package) - gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false) - else - append_to_file("config/importmap.rb", "#{pin}\n", verbose: false) - end + pin = packager.vendored_pin_for(package, url, options[:preload], integrity: integrity_hash) + + log_integrity_usage(integrity_hash) + update_importmap_with_pin(package, pin) end - else - puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}" end end @@ -35,33 +33,31 @@ def pin(*packages) option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" def unpin(*packages) - if imports = packager.import(*packages, env: options[:env], from: options[:from]) + with_import_response(packages, env: options[:env], from: options[:from]) do |imports, _integrity_hashes| imports.each do |package, url| if packager.packaged?(package) puts %(Unpinning and removing "#{package}") packager.remove(package) end end - else - puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}" end end desc "pristine", "Redownload all pinned packages" option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" + option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM" def pristine - packages = npm.packages_with_versions.map do |p, v| - v.blank? ? p : [p, v].join("@") - end + packages = prepare_packages_with_versions - if imports = packager.import(*packages, env: options[:env], from: options[:from]) - imports.each do |package, url| + with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes| + process_imports(imports, integrity_hashes) do |package, url, integrity_hash| puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url}) + packager.download(package, url) + + log_integrity_usage(integrity_hash) end - else - puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}" end end @@ -122,6 +118,33 @@ def packages puts npm.packages_with_versions.map { |x| x.join(' ') } end + desc "integrity [*PACKAGES]", "Download and add integrity hashes for packages" + option :env, type: :string, aliases: :e, default: "production" + option :from, type: :string, aliases: :f, default: "jspm" + option :update, type: :boolean, aliases: :u, default: false, desc: "Update importmap.rb with integrity hashes" + def integrity(*packages) + packages = prepare_packages_with_versions(packages) + + with_import_response(packages, env: options[:env], from: options[:from], integrity: true) do |imports, integrity_hashes| + process_imports(imports, integrity_hashes) do |package, url, integrity_hash| + puts %(Getting integrity for "#{package}" from #{url}) + + if integrity_hash + puts %( #{package}: #{integrity_hash}) + + if options[:update] + pin_with_integrity = packager.pin_for(package, url, integrity: integrity_hash) + + update_importmap_with_pin(package, pin_with_integrity) + puts %( Updated importmap.rb with integrity for "#{package}") + end + else + puts %( No integrity hash available for "#{package}") + end + end + end + end + private def packager @packager ||= Importmap::Packager.new @@ -131,6 +154,22 @@ def npm @npm ||= Importmap::Npm.new end + def update_importmap_with_pin(package, pin) + if packager.packaged?(package) + gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false) + else + append_to_file("config/importmap.rb", "#{pin}\n", verbose: false) + end + end + + def log_integrity_usage(integrity_hash) + puts %( Using integrity: #{integrity_hash}) if integrity_hash + end + + def handle_package_not_found(packages, from) + puts "Couldn't find any packages in #{packages.inspect} on #{from}" + end + def remove_line_from_file(path, pattern) path = File.expand_path(path, destination_root) @@ -155,6 +194,33 @@ def puts_table(array) puts divider if row_number == 0 end end + + def prepare_packages_with_versions(packages = []) + if packages.empty? + npm.packages_with_versions.map do |p, v| + v.blank? ? p : [p, v].join("@") + end + else + packages + end + end + + def process_imports(imports, integrity_hashes, &block) + imports.each do |package, url| + integrity_hash = integrity_hashes[url] + block.call(package, url, integrity_hash) + end + end + + def with_import_response(packages, **options) + response = packager.import(*packages, **options) + + if response + yield response[:imports], response[:integrity] + else + handle_package_not_found(packages, options[:from]) + end + end end Importmap::Commands.start(ARGV) diff --git a/lib/importmap/map.rb b/lib/importmap/map.rb index 5d0cd62..63fa2ef 100644 --- a/lib/importmap/map.rb +++ b/lib/importmap/map.rb @@ -25,14 +25,14 @@ def draw(path = nil, &block) self end - def pin(name, to: nil, preload: true) + def pin(name, to: nil, preload: true, integrity: nil) clear_cache - @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload) + @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity) end - def pin_all_from(dir, under: nil, to: nil, preload: true) + def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: nil) clear_cache - @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload) + @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity) end # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to @@ -41,8 +41,72 @@ def pin_all_from(dir, under: nil, to: nil, preload: true) # resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for # the different cases. def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :preloaded_module_paths) + preloaded_module_packages(resolver: resolver, entry_point: entry_point, cache_key: cache_key).keys + end + + # Returns a hash of resolved module paths to their corresponding package objects for all pinned packages + # that are marked for preloading. The hash keys are the resolved asset paths, and the values are the + # +MappedFile+ objects containing package metadata including name, path, preload setting, and integrity. + # + # The +resolver+ must respond to +path_to_asset+, such as +ActionController::Base.helpers+ or + # +ApplicationController.helpers+. You'll want to use the resolver that has been configured for the + # +asset_host+ you want these resolved paths to use. + # + # ==== Parameters + # + # [+resolver+] + # An object that responds to +path_to_asset+ for resolving asset paths. + # + # [+entry_point+] + # The entry point name or array of entry point names to determine which packages should be preloaded. + # Defaults to +"application"+. Packages with +preload: true+ are always included regardless of entry point. + # Packages with specific entry point names (e.g., +preload: "admin"+) are only included when that entry + # point is specified. + # + # [+cache_key+] + # A custom cache key to vary the cache used by this method for different cases, such as resolving + # for different asset hosts. Defaults to +:preloaded_module_packages+. + # + # ==== Returns + # + # A hash where: + # * Keys are resolved asset paths (strings) + # * Values are +MappedFile+ objects with +name+, +path+, +preload+, and +integrity+ attributes + # + # Missing assets are gracefully handled and excluded from the returned hash. + # + # ==== Examples + # + # # Get all preloaded packages for the default "application" entry point + # packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + # # => { "/assets/application-abc123.js" => #, + # # "https://cdn.skypack.dev/react" => # } + # + # # Get preloaded packages for a specific entry point + # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: "admin") + # + # # Get preloaded packages for multiple entry points + # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: ["application", "admin"]) + # + # # Use a custom cache key for different asset hosts + # packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host") + def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages) cache_as(cache_key) do - resolve_asset_paths(expanded_preloading_packages_and_directories(entry_point:), resolver:).values + expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package| + resolved_path = resolve_asset_path(package.path, resolver: resolver) + next unless resolved_path + + resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver) + + package = MappedFile.new( + name: package.name, + path: package.path, + preload: package.preload, + integrity: resolved_integrity + ) + + [resolved_path, package] + end.to_h end end @@ -53,7 +117,9 @@ def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :pr # `cache_key` to vary the cache used by this method for the different cases. def to_json(resolver:, cache_key: :json) cache_as(cache_key) do - JSON.pretty_generate({ "imports" => resolve_asset_paths(expanded_packages_and_directories, resolver: resolver) }) + packages = expanded_packages_and_directories + map = build_import_map(packages, resolver: resolver) + JSON.pretty_generate(map) end end @@ -84,8 +150,8 @@ def cache_sweeper(watches: nil) end private - MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true) - MappedFile = Struct.new(:name, :path, :preload, keyword_init: true) + MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true) + MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true) def cache_as(name) if result = @cache[name.to_s] @@ -105,19 +171,53 @@ def rescuable_asset_error?(error) def resolve_asset_paths(paths, resolver:) paths.transform_values do |mapping| - begin - resolver.path_to_asset(mapping.path) - rescue => e - if rescuable_asset_error?(e) - Rails.logger.warn "Importmap skipped missing path: #{mapping.path}" - nil - else - raise e - end - end + resolve_asset_path(mapping.path, resolver:) end.compact end + def resolve_asset_path(path, resolver:) + begin + resolver.path_to_asset(path) + rescue => e + if rescuable_asset_error?(e) + Rails.logger.warn "Importmap skipped missing path: #{path}" + nil + else + raise e + end + end + end + + def build_import_map(packages, resolver:) + map = { "imports" => resolve_asset_paths(packages, resolver: resolver) } + integrity = build_integrity_hash(packages, resolver: resolver) + map["integrity"] = integrity unless integrity.empty? + map + end + + def build_integrity_hash(packages, resolver:) + packages.filter_map do |name, mapping| + next unless mapping.integrity + + resolved_path = resolve_asset_path(mapping.path, resolver: resolver) + next unless resolved_path + + integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver) + next unless integrity_value + + [resolved_path, integrity_value] + end.to_h + end + + def resolve_integrity_value(integrity, path, resolver:) + case integrity + when true + resolver.asset_integrity(path) if resolver.respond_to?(:asset_integrity) + when String + integrity + end + end + def expanded_preloading_packages_and_directories(entry_point:) expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? } end @@ -134,7 +234,12 @@ def expand_directories_into(paths) module_name = module_name_from(module_filename, mapping) module_path = module_path_from(module_filename, mapping) - paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload) + paths[module_name] = MappedFile.new( + name: module_name, + path: module_path, + preload: mapping.preload, + integrity: mapping.integrity + ) end end end diff --git a/lib/importmap/packager.rb b/lib/importmap/packager.rb index 9a8a889..98819d1 100644 --- a/lib/importmap/packager.rb +++ b/lib/importmap/packager.rb @@ -17,34 +17,39 @@ def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/java @vendor_path = Pathname.new(vendor_path) end - def import(*packages, env: "production", from: "jspm") + def import(*packages, env: "production", from: "jspm", integrity: false) response = post_json({ "install" => Array(packages), "flattenScope" => true, "env" => [ "browser", "module", env ], - "provider" => normalize_provider(from) + "provider" => normalize_provider(from), + "integrity" => integrity }) case response.code - when "200" then extract_parsed_imports(response) - when "404", "401" then nil - else handle_failure_response(response) + when "200" + extract_parsed_response(response) + when "404", "401" + nil + else + handle_failure_response(response) end end - def pin_for(package, url) - %(pin "#{package}", to: "#{url}") + def pin_for(package, url = nil, preloads: nil, integrity: nil) + to = url ? %(, to: "#{url}") : "" + preload_param = preload(preloads) + integrity_param = integrity ? %(, integrity: "#{integrity}") : "" + + %(pin "#{package}") + to + preload_param + integrity_param end - def vendored_pin_for(package, url, preloads = nil) + def vendored_pin_for(package, url, preloads = nil, integrity: nil) filename = package_filename(package) version = extract_package_version_from(url) + to = "#{package}.js" != filename ? filename : nil - if "#{package}.js" == filename - %(pin "#{package}"#{preload(preloads)} # #{version}) - else - %(pin "#{package}", to: "#{filename}"#{preload(preloads)} # #{version}) - end + pin_for(package, to, preloads: preloads, integrity: integrity) + %( # #{version}) end def packaged?(package) @@ -88,8 +93,15 @@ def normalize_provider(name) name.to_s == "jspm" ? "jspm.io" : name.to_s end - def extract_parsed_imports(response) - JSON.parse(response.body).dig("map", "imports") + def extract_parsed_response(response) + parsed = JSON.parse(response.body) + imports = parsed.dig("map", "imports") + integrity = parsed.dig("map", "integrity") || {} + + { + imports: imports, + integrity: integrity + } end def handle_failure_response(response) diff --git a/test/commands_test.rb b/test/commands_test.rb index d5b3c27..410248b 100644 --- a/test/commands_test.rb +++ b/test/commands_test.rb @@ -8,7 +8,6 @@ class CommandsTest < ActiveSupport::TestCase @tmpdir = Dir.mktmpdir FileUtils.cp_r("#{__dir__}/dummy", @tmpdir) Dir.chdir("#{@tmpdir}/dummy") - FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin") end teardown do @@ -16,32 +15,28 @@ class CommandsTest < ActiveSupport::TestCase end test "json command prints JSON with imports" do - out, err = run_importmap_command("json") + out, _err = run_importmap_command("json") + assert_includes JSON.parse(out), "imports" end test "update command prints message of no outdated packages" do out, _err = run_importmap_command("update") + assert_includes out, "No outdated" end test "update command prints confirmation of pin with outdated packages" do - @tmpdir = Dir.mktmpdir - FileUtils.cp_r("#{__dir__}/dummy", @tmpdir) - Dir.chdir("#{@tmpdir}/dummy") FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb") - FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin") out, _err = run_importmap_command("update") + assert_includes out, "Pinning" end test "pristine command redownloads all pinned packages" do - @tmpdir = Dir.mktmpdir - FileUtils.cp_r("#{__dir__}/dummy", @tmpdir) - Dir.chdir("#{@tmpdir}/dummy") FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb") - FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin") + out, _err = run_importmap_command("pin", "md5@2.2.0") assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' @@ -55,6 +50,149 @@ class CommandsTest < ActiveSupport::TestCase assert_equal original, File.read("#{@tmpdir}/dummy/vendor/javascript/md5.js") end + test "pin command includes integrity by default" do + out, _err = run_importmap_command("pin", "md5@2.2.0") + + assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5", integrity: "sha384-' + end + + test "pin command with --no-integrity option excludes integrity" do + out, _err = run_importmap_command("pin", "md5@2.2.0", "--no-integrity") + + assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_not_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5" # @2.2.0' + end + + test "pristine command includes integrity by default" do + FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb") + + out, _err = run_importmap_command("pristine") + + assert_includes out, 'Downloading "md5" to vendor/javascript/md5.js from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'Using integrity:' + end + + test "pristine command with --no-integrity option excludes integrity" do + FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb") + + out, _err = run_importmap_command("pristine", "--no-integrity") + + assert_includes out, 'Downloading "md5" to vendor/javascript/md5.js from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_not_includes out, 'Using integrity:' + end + + test "pin command with explicit --integrity option includes integrity" do + out, _err = run_importmap_command("pin", "md5@2.2.0", "--integrity") + + assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'integrity: "sha384-' + end + + test "pin command with multiple packages includes integrity for all" do + out, _err = run_importmap_command("pin", "md5@2.2.0", "lodash@4.17.21") + + assert_includes out, 'Pinning "md5"' + assert_includes out, 'Pinning "lodash"' + assert_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5"' + assert_includes config_content, 'pin "lodash"' + + md5_lines = config_content.lines.select { |line| line.include?('pin "md5"') } + lodash_lines = config_content.lines.select { |line| line.include?('pin "lodash"') } + assert md5_lines.any? { |line| line.include?('integrity:') } + assert lodash_lines.any? { |line| line.include?('integrity:') } + end + + test "pin command with preload option includes integrity and preload" do + out, _err = run_importmap_command("pin", "md5@2.2.0", "--preload", "true") + + assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'preload: true' + assert_includes config_content, 'integrity: "sha384-' + end + + test "integrity command shows integrity hashes for specific packages" do + out, _err = run_importmap_command("integrity", "md5@2.2.0") + + assert_includes out, 'Getting integrity for "md5" from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'md5: sha384-' + end + + test "integrity command with --update option updates importmap.rb" do + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5", to: "https://cdn.skypack.dev/md5", preload: true' + + out, _err = run_importmap_command("integrity", "md5@2.2.0", "--update") + + assert_includes out, 'Getting integrity for "md5" from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'md5: sha384-' + assert_includes out, 'Updated importmap.rb with integrity for "md5"' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5", to: "https://ga.jspm.io/npm:md5@2.2.0/md5.js", integrity: "sha384-' + end + + test "integrity command with multiple packages shows integrity for all" do + out, _err = run_importmap_command("integrity", "md5@2.2.0", "lodash@4.17.21") + + assert_includes out, 'Getting integrity for "md5"' + assert_includes out, 'Getting integrity for "lodash"' + assert_includes out, 'md5: sha384-' + assert_includes out, 'lodash: sha384-' + end + + test "integrity command without packages shows integrity for all remote packages" do + run_importmap_command("pin", "md5@2.2.0", "--no-integrity") + + out, _err = run_importmap_command("integrity") + + assert_includes out, 'Getting integrity for "md5"' + assert_includes out, 'md5: sha384-' + end + + test "integrity command with --update updates multiple packages" do + run_importmap_command("pin", "md5@2.2.0", "--no-integrity") + run_importmap_command("pin", "lodash@4.17.21", "--no-integrity") + + out, _err = run_importmap_command("integrity", "--update") + + assert_includes out, 'Updated importmap.rb with integrity for "md5"' + assert_includes out, 'Updated importmap.rb with integrity for "lodash"' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5", to: "https://ga.jspm.io/npm:md5@2.2.0/md5.js", integrity: "sha384-' + assert_includes config_content, 'pin "lodash", to: "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js", integrity: "sha384-' + end + + test "integrity command with env option" do + out, _err = run_importmap_command("integrity", "md5@2.2.0", "--env", "development") + + assert_includes out, 'Getting integrity for "md5"' + assert_includes out, 'md5: sha384-' + end + + test "integrity command with from option" do + out, _err = run_importmap_command("integrity", "md5@2.2.0", "--from", "jspm") + + assert_includes out, 'Getting integrity for "md5"' + assert_includes out, 'md5: sha384-' + end + private def run_importmap_command(command, *args) capture_subprocess_io { system("bin/importmap", command, *args, exception: true) } diff --git a/test/dummy/bin/importmap b/test/dummy/bin/importmap new file mode 100755 index 0000000..36502ab --- /dev/null +++ b/test/dummy/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/test/dummy/config/importmap.rb b/test/dummy/config/importmap.rb index 850ca72..2fece22 100644 --- a/test/dummy/config/importmap.rb +++ b/test/dummy/config/importmap.rb @@ -2,3 +2,4 @@ pin "md5", to: "https://cdn.skypack.dev/md5", preload: true pin "not_there", to: "nowhere.js", preload: false +pin "rich_text", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" diff --git a/test/dummy/config/initializers/assets.rb b/test/dummy/config/initializers/assets.rb index 969a5d8..8edcc35 100644 --- a/test/dummy/config/initializers/assets.rb +++ b/test/dummy/config/initializers/assets.rb @@ -11,3 +11,5 @@ # application.js, application.css, and all non-JS/CSS in the app/assets # folder are already added. # Rails.application.config.assets.precompile += %w( admin.js admin.css ) + +Rails.application.config.assets.integrity_hash_algorithm = "sha384" diff --git a/test/importmap_tags_helper_test.rb b/test/importmap_tags_helper_test.rb index 6796aa9..21a8ef9 100644 --- a/test/importmap_tags_helper_test.rb +++ b/test/importmap_tags_helper_test.rb @@ -20,21 +20,39 @@ def content_security_policy_nonce end test "javascript_inline_importmap_tag" do - assert_match \ - %r{}, + assert_dom_equal( + %( + + ), javascript_inline_importmap_tag + ) end test "javascript_importmap_module_preload_tags" do - assert_dom_equal \ - %(), + assert_dom_equal( + %( + + + ), javascript_importmap_module_preload_tags + ) end test "tags have no nonce if CSP is not configured" do @request = FakeRequest.new - assert_no_match /nonce/, javascript_importmap_tags("application") + assert_no_match(/nonce/, javascript_importmap_tags("application")) ensure @request = nil end @@ -42,9 +60,9 @@ def content_security_policy_nonce test "tags have nonce if CSP is configured" do @request = FakeRequest.new("iyhD0Yc0W+c=") - assert_match /nonce="iyhD0Yc0W\+c="/, javascript_inline_importmap_tag - assert_match /nonce="iyhD0Yc0W\+c="/, javascript_import_module_tag("application") - assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_module_preload_tags + assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_inline_importmap_tag) + assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_import_module_tag("application")) + assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_importmap_module_preload_tags) ensure @request = nil end diff --git a/test/importmap_test.rb b/test/importmap_test.rb index bfb60b3..0f341d4 100644 --- a/test/importmap_test.rb +++ b/test/importmap_test.rb @@ -1,12 +1,13 @@ require "test_helper" +require "minitest/mock" class ImportmapTest < ActiveSupport::TestCase def setup @importmap = Importmap::Map.new.tap do |map| map.draw do pin "application", preload: false - pin "editor", to: "rich_text.js", preload: false - pin "not_there", to: "nowhere.js", preload: false + pin "editor", to: "rich_text.js", preload: false, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" + pin "not_there", to: "nowhere.js", preload: false, integrity: "sha384-somefakehash" pin "md5", to: "https://cdn.skypack.dev/md5", preload: true pin "leaflet", to: "https://cdn.skypack.dev/leaflet", preload: 'application' pin "chartkick", to: "https://cdn.skypack.dev/chartkick", preload: ['application', 'alternate'] @@ -30,6 +31,22 @@ def setup assert_match %r|assets/rich_text-.*\.js|, generate_importmap_json["imports"]["editor"] end + test "local pin with integrity" do + editor_path = generate_importmap_json["imports"]["editor"] + assert_match %r|assets/rich_text-.*\.js|, editor_path + assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", generate_importmap_json["integrity"][editor_path] + assert_nil generate_importmap_json["imports"]["not_there"] + assert_not_includes generate_importmap_json["integrity"].values, "sha384-somefakehash" + end + + test "integrity is not present if there is no integrity set in the map" do + @importmap = Importmap::Map.new.tap do |map| + map.pin "application", preload: false + end + + assert_not generate_importmap_json.key?("integrity") + end + test "local pin missing is removed from generated importmap" do assert_nil generate_importmap_json["imports"]["not_there"] end @@ -73,6 +90,52 @@ def setup assert_match %r|assets/my_lib-.*\.js|, generate_importmap_json["imports"]["my_lib"] end + test "importmap json includes integrity hashes from integrity: true" do + importmap = Importmap::Map.new.tap do |map| + map.pin "application", integrity: true + end + + json = JSON.parse(importmap.to_json(resolver: ApplicationController.helpers)) + + assert json["integrity"], "Should include integrity section" + + application_path = json["imports"]["application"] + assert application_path, "Should include application in imports" + if ENV["ASSETS_PIPELINE"] == "sprockets" + assert_equal "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", json["integrity"][application_path] + else + assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", json["integrity"][application_path] + end + end + + test "integrity: true with missing asset should be gracefully handled" do + importmap = Importmap::Map.new.tap do |map| + map.pin "missing", to: "nonexistent.js", preload: true, integrity: true + end + + json = JSON.parse(importmap.to_json(resolver: ApplicationController.helpers)) + + assert_empty json["imports"] + assert_nil json["integrity"] + end + + test "integrity: true with resolver that doesn't have asset_integrity method returns nil" do + mock_resolver = Minitest::Mock.new + + mock_resolver.expect(:path_to_asset, "/assets/application-abc123.js", ["application.js"]) + mock_resolver.expect(:path_to_asset, "/assets/application-abc123.js", ["application.js"]) + + importmap = Importmap::Map.new.tap do |map| + map.pin "application", integrity: true + end + + json = JSON.parse(importmap.to_json(resolver: mock_resolver)) + + assert json["imports"]["application"] + assert_match %r|/assets/application-.*\.js|, json["imports"]["application"] + assert_nil json["integrity"] + end + test 'invalid importmap file results in error' do file = file_fixture('invalid_import_map.rb') importmap = Importmap::Map.new @@ -83,35 +146,35 @@ def setup test "preloaded modules are included in preload tags when no entry_point specified" do preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers).to_s - assert_match /md5/, preloading_module_paths - assert_match /goodbye_controller/, preloading_module_paths - assert_match /leaflet/, preloading_module_paths - assert_no_match /application/, preloading_module_paths - assert_no_match /tinymce/, preloading_module_paths + assert_match(/md5/, preloading_module_paths) + assert_match(/goodbye_controller/, preloading_module_paths) + assert_match(/leaflet/, preloading_module_paths) + assert_no_match(/application/, preloading_module_paths) + assert_no_match(/tinymce/, preloading_module_paths) end test "preloaded modules are included in preload tags based on single entry_point provided" do preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, entry_point: "alternate").to_s - assert_no_match /leaflet/, preloading_module_paths - assert_match /tinymce/, preloading_module_paths - assert_match /chartkick/, preloading_module_paths - assert_match /md5/, preloading_module_paths - assert_match /goodbye_controller/, preloading_module_paths - assert_no_match /application/, preloading_module_paths + assert_no_match(/leaflet/, preloading_module_paths) + assert_match(/tinymce/, preloading_module_paths) + assert_match(/chartkick/, preloading_module_paths) + assert_match(/md5/, preloading_module_paths) + assert_match(/goodbye_controller/, preloading_module_paths) + assert_no_match(/application/, preloading_module_paths) end test "preloaded modules are included in preload tags based on multiple entry_points provided" do preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, entry_point: ["application", "alternate"]).to_s - assert_match /leaflet/, preloading_module_paths - assert_match /tinymce/, preloading_module_paths - assert_match /chartkick/, preloading_module_paths - assert_match /md5/, preloading_module_paths - assert_match /goodbye_controller/, preloading_module_paths - assert_no_match /application/, preloading_module_paths + assert_match(/leaflet/, preloading_module_paths) + assert_match(/tinymce/, preloading_module_paths) + assert_match(/chartkick/, preloading_module_paths) + assert_match(/md5/, preloading_module_paths) + assert_match(/goodbye_controller/, preloading_module_paths) + assert_no_match(/application/, preloading_module_paths) end test "digest" do - assert_match /^\w{40}$/, @importmap.digest(resolver: ApplicationController.helpers) + assert_match(/^\w{40}$/, @importmap.digest(resolver: ApplicationController.helpers)) end test "separate caches" do @@ -136,8 +199,164 @@ def setup assert_not_equal set_two, @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, cache_key: "2").to_s end + test "preloaded_module_packages returns hash of resolved paths to packages when no entry_point specified" do + packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + md5 = packages["https://cdn.skypack.dev/md5"] + assert md5, "Should include md5 package" + assert_equal "md5", md5.name + assert_equal "https://cdn.skypack.dev/md5", md5.path + assert_equal true, md5.preload + + goodbye_controller_path = packages.keys.find { |path| path.include?("goodbye_controller") } + assert goodbye_controller_path, "Should include goodbye_controller package" + assert_equal "controllers/goodbye_controller", packages[goodbye_controller_path].name + assert_equal true, packages[goodbye_controller_path].preload + + leaflet = packages["https://cdn.skypack.dev/leaflet"] + assert leaflet, "Should include leaflet package" + assert_equal "leaflet", leaflet.name + assert_equal "https://cdn.skypack.dev/leaflet", leaflet.path + assert_equal 'application', leaflet.preload + + chartkick = packages["https://cdn.skypack.dev/chartkick"] + assert chartkick, "Should include chartkick package" + assert_equal "chartkick", chartkick.name + assert_equal ['application', 'alternate'], chartkick.preload + + application_path = packages.keys.find { |path| path.include?("application") } + assert_nil application_path, "Should not include application package (preload: false)" + + tinymce_path = packages.keys.find { |path| path.include?("tinymce") } + assert_nil tinymce_path, "Should not include tinymce package (preload: 'alternate')" + end + + test "preloaded_module_packages returns hash based on single entry_point provided" do + packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, entry_point: "alternate") + + tinymce = packages["https://cdn.skypack.dev/tinymce"] + assert tinymce, "Should include tinymce package for alternate entry point" + assert_equal "tinyMCE", tinymce.name + assert_equal "https://cdn.skypack.dev/tinymce", tinymce.path + assert_equal 'alternate', tinymce.preload + + # Should include packages for multiple entry points (chartkick preloads for both 'application' and 'alternate') + chartkick = packages["https://cdn.skypack.dev/chartkick"] + assert chartkick, "Should include chartkick package" + assert_equal "chartkick", chartkick.name + assert_equal ['application', 'alternate'], chartkick.preload + + # Should include always-preloaded packages + md5 = packages["https://cdn.skypack.dev/md5"] + assert md5, "Should include md5 package (always preloaded)" + + leaflet_path = packages.keys.find { |path| path.include?("leaflet") } + assert_nil leaflet_path, "Should not include leaflet package (preload: 'application' only)" + end + + test "preloaded_module_packages returns hash based on multiple entry_points provided" do + packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, entry_point: ["application", "alternate"]) + + leaflet = packages["https://cdn.skypack.dev/leaflet"] + assert leaflet, "Should include leaflet package for application entry point" + + # Should include packages for 'alternate' entry point + tinymce = packages["https://cdn.skypack.dev/tinymce"] + assert tinymce, "Should include tinymce package for alternate entry point" + + # Should include packages for multiple entry points + chartkick = packages["https://cdn.skypack.dev/chartkick"] + assert chartkick, "Should include chartkick package for both entry points" + + # Should include always-preloaded packages + md5 = packages["https://cdn.skypack.dev/md5"] + assert md5, "Should include md5 package (always preloaded)" + + application_path = packages.keys.find { |path| path.include?("application") } + assert_nil application_path, "Should not include application package (preload: false)" + end + + test "preloaded_module_packages includes package integrity when present" do + # Create a new importmap with a preloaded package that has integrity + importmap = Importmap::Map.new.tap do |map| + map.pin "editor", to: "rich_text.js", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" + end + + packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + editor_path = packages.keys.find { |path| path.include?("rich_text") } + assert editor_path, "Should include editor package" + assert_equal "editor", packages[editor_path].name + assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity + end + + test "pin with integrity: true should calculate integrity dynamically" do + importmap = Importmap::Map.new.tap do |map| + map.pin "editor", to: "rich_text.js", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" + end + + packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + editor_path = packages.keys.find { |path| path.include?("rich_text") } + assert editor_path, "Should include editor package" + assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity + end + + test "preloaded_module_packages uses custom cache_key" do + set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s + + ActionController::Base.asset_host = "http://assets.example.com" + + set_two = @importmap.preloaded_module_packages(resolver: ActionController::Base.helpers, cache_key: "2").to_s + + assert_not_equal set_one, set_two + ensure + ActionController::Base.asset_host = nil + end + + test "preloaded_module_packages caches reset" do + set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s + set_two = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "2").to_s + + @importmap.pin "something", to: "https://cdn.example.com/somewhere.js", preload: true + + assert_not_equal set_one, @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s + assert_not_equal set_two, @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "2").to_s + end + + test "preloaded_module_packages handles missing assets gracefully" do + importmap = Importmap::Map.new.tap do |map| + map.pin "existing", to: "application.js", preload: true + map.pin "missing", to: "nonexistent.js", preload: true + end + + packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + assert_equal 1, packages.size + + existing_path = packages.keys.find { |path| path&.include?("application") } + assert existing_path, "Should include existing asset" + end + + test "pin_all_from with integrity: true should calculate integrity dynamically" do + importmap = Importmap::Map.new.tap do |map| + map.pin_all_from "app/javascript/controllers", under: "controllers", integrity: true + end + + packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + controller_path = packages.keys.find { |path| path.include?("goodbye_controller") } + assert controller_path, "Should include goodbye_controller package" + if ENV["ASSETS_PIPELINE"] == "sprockets" + assert_equal "sha256-6yWqFiaT8vQURc/OiKuIrEv9e/y4DMV/7nh7s5o3svA=", packages[controller_path].integrity + else + assert_equal "sha384-k7HGo2DomvN21em+AypqCekIFE3quejFnjQp3NtEIMyvFNpIdKThZhxr48anSNmP", packages[controller_path].integrity + end + assert_not_includes packages.map { |_, v| v.integrity }, nil + end + private def generate_importmap_json - JSON.parse @importmap.to_json(resolver: ApplicationController.helpers) + @generate_importmap_json ||= JSON.parse @importmap.to_json(resolver: ApplicationController.helpers) end end diff --git a/test/packager_integration_test.rb b/test/packager_integration_test.rb index 3600065..148a65e 100644 --- a/test/packager_integration_test.rb +++ b/test/packager_integration_test.rb @@ -5,7 +5,8 @@ class Importmap::PackagerIntegrationTest < ActiveSupport::TestCase setup { @packager = Importmap::Packager.new(Rails.root.join("config/importmap.rb")) } test "successful import against live service" do - assert_equal "https://ga.jspm.io/npm:react@17.0.2/index.js", @packager.import("react@17.0.2")["react"] + result = @packager.import("react@17.0.2") + assert_equal "https://ga.jspm.io/npm:react@17.0.2/index.js", result[:imports]["react"] end test "missing import against live service" do @@ -40,7 +41,6 @@ class Importmap::PackagerIntegrationTest < ActiveSupport::TestCase @packager.download("react", package_url) assert File.exist?(vendored_package_file) assert_equal "// react@17.0.2 downloaded from #{package_url}", File.readlines(vendored_package_file).first.strip - @packager.remove("react") assert_not File.exist?(Pathname.new(vendor_dir).join("react.js")) end diff --git a/test/packager_test.rb b/test/packager_test.rb index a4ba75c..ad36705 100644 --- a/test/packager_test.rb +++ b/test/packager_test.rb @@ -22,7 +22,9 @@ def code() "200" end end.new @packager.stub(:post_json, response) do - assert_equal(response.imports, @packager.import("react@17.0.2")) + result = @packager.import("react@17.0.2") + assert_equal response.imports, result[:imports] + assert_equal({}, result[:integrity]) end end @@ -49,6 +51,51 @@ def code() "200" end test "pin_for" do assert_equal %(pin "react", to: "https://cdn/react"), @packager.pin_for("react", "https://cdn/react") + assert_equal( + %(pin "react", to: "https://cdn/react", integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", to: "https://cdn/react"), + @packager.pin_for("react", "https://cdn/react", integrity: nil) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: true), + @packager.pin_for("react", "https://cdn/react", preloads: ["true"]) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: false), + @packager.pin_for("react", "https://cdn/react", preloads: ["false"]) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: "foo"), + @packager.pin_for("react", "https://cdn/react", preloads: ["foo"]) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: ["foo", "bar"]), + @packager.pin_for("react", "https://cdn/react", preloads: ["foo", "bar"]) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: true, integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", preloads: ["true"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: false, integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", preloads: ["false"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: "foo", integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", preloads: ["foo"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: ["foo", "bar"], integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", preloads: ["foo", "bar"], integrity: "sha384-abcdef") + ) + + assert_equal %(pin "react"), @packager.pin_for("react") + assert_equal %(pin "react", preload: true), @packager.pin_for("react", preloads: ["true"]) + assert_equal %(pin "react", integrity: "sha384-abcdef"), @packager.pin_for("react", integrity: "sha384-abcdef") + assert_equal %(pin "react", preload: true, integrity: "sha384-abcdef"), @packager.pin_for("react", preloads: ["true"], integrity: "sha384-abcdef") end test "vendored_pin_for" do @@ -58,5 +105,71 @@ def code() "200" end assert_equal %(pin "react", preload: false # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["false"]) assert_equal %(pin "react", preload: "foo" # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo"]) assert_equal %(pin "react", preload: ["foo", "bar"] # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo", "bar"]) + assert_equal( + %(pin "react", integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", nil, integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "javascript/react", to: "javascript--react.js", integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("javascript/react", "https://cdn/react@17.0.2", nil, integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", preload: true, integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["true"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", preload: false, integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["false"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", preload: "foo", integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", preload: ["foo", "bar"], integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo", "bar"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", nil, integrity: nil) + ) + end + + test "import with integrity parameter" do + response = Class.new do + def body + { + "map" => { + "imports" => imports, + "integrity" => integrity_map + } + }.to_json + end + + def imports + { + "react" => "https://ga.jspm.io/npm:react@17.0.2/index.js", + "object-assign" => "https://ga.jspm.io/npm:object-assign@4.1.1/index.js" + } + end + + def integrity_map + { + "https://ga.jspm.io/npm:react@17.0.2/index.js" => "sha384-abcdef1234567890", + "https://ga.jspm.io/npm:object-assign@4.1.1/index.js" => "sha384-1234567890abcdef" + } + end + + def code() "200" end + end.new + + @packager.stub(:post_json, response) do + result = @packager.import("react@17.0.2", integrity: true) + assert_equal response.imports, result[:imports] + assert_equal({ + "https://ga.jspm.io/npm:react@17.0.2/index.js" => "sha384-abcdef1234567890", + "https://ga.jspm.io/npm:object-assign@4.1.1/index.js" => "sha384-1234567890abcdef" + }, result[:integrity]) + end end end diff --git a/test/reloader_test.rb b/test/reloader_test.rb index e366b92..60de5f4 100644 --- a/test/reloader_test.rb +++ b/test/reloader_test.rb @@ -16,7 +16,7 @@ class ReloaderTest < ActiveSupport::TestCase Rails.application.importmap = Importmap::Map.new.draw { pin "md5", to: "https://cdn.skypack.dev/md5" } assert_not_predicate @reloader, :updated? - assert_changes -> { Rails.application.importmap.packages.keys }, from: %w[ md5 ], to: %w[ md5 not_there ] do + assert_changes -> { Rails.application.importmap.packages.keys }, from: %w[ md5 ], to: %w[ md5 not_there rich_text ] do touch_config assert @reloader.execute_if_updated end