diff options
Diffstat (limited to 'actionpack/lib/action_dispatch')
25 files changed, 398 insertions, 112 deletions
diff --git a/actionpack/lib/action_dispatch/http/cache.rb b/actionpack/lib/action_dispatch/http/cache.rb index 7be30be77a..0258d85564 100644 --- a/actionpack/lib/action_dispatch/http/cache.rb +++ b/actionpack/lib/action_dispatch/http/cache.rb @@ -150,8 +150,8 @@ module ActionDispatch directive, argument = segment.split("=", 2) if SPECIAL_KEYS.include? directive - key = directive.tr("-", "_") - cache_control[key.to_sym] = argument || true + directive.tr!("-", "_") + cache_control[directive.to_sym] = argument || true else cache_control[:extras] ||= [] cache_control[:extras] << segment diff --git a/actionpack/lib/action_dispatch/http/content_security_policy.rb b/actionpack/lib/action_dispatch/http/content_security_policy.rb index 5c6fa2dfa7..e8cf1b95a5 100644 --- a/actionpack/lib/action_dispatch/http/content_security_policy.rb +++ b/actionpack/lib/action_dispatch/http/content_security_policy.rb @@ -22,8 +22,9 @@ module ActionDispatch #:nodoc: if policy = request.content_security_policy nonce = request.content_security_policy_nonce + nonce_directives = request.content_security_policy_nonce_directives context = request.controller_instance || request - headers[header_name(request)] = policy.build(context, nonce) + headers[header_name(request)] = policy.build(context, nonce, nonce_directives) end response @@ -32,7 +33,7 @@ module ActionDispatch #:nodoc: private def html_response?(headers) if content_type = headers[CONTENT_TYPE] - content_type =~ /html/ + /html/.match?(content_type) end end @@ -54,6 +55,7 @@ module ActionDispatch #:nodoc: POLICY_REPORT_ONLY = "action_dispatch.content_security_policy_report_only" NONCE_GENERATOR = "action_dispatch.content_security_policy_nonce_generator" NONCE = "action_dispatch.content_security_policy_nonce" + NONCE_DIRECTIVES = "action_dispatch.content_security_policy_nonce_directives" def content_security_policy get_header(POLICY) @@ -79,6 +81,14 @@ module ActionDispatch #:nodoc: set_header(NONCE_GENERATOR, generator) end + def content_security_policy_nonce_directives + get_header(NONCE_DIRECTIVES) + end + + def content_security_policy_nonce_directives=(generator) + set_header(NONCE_DIRECTIVES, generator) + end + def content_security_policy_nonce if content_security_policy_nonce_generator if nonce = get_header(NONCE) @@ -127,13 +137,17 @@ module ActionDispatch #:nodoc: object_src: "object-src", prefetch_src: "prefetch-src", script_src: "script-src", + script_src_attr: "script-src-attr", + script_src_elem: "script-src-elem", style_src: "style-src", + style_src_attr: "style-src-attr", + style_src_elem: "style-src-elem", worker_src: "worker-src" }.freeze - NONCE_DIRECTIVES = %w[script-src style-src].freeze + DEFAULT_NONCE_DIRECTIVES = %w[script-src style-src].freeze - private_constant :MAPPINGS, :DIRECTIVES, :NONCE_DIRECTIVES + private_constant :MAPPINGS, :DIRECTIVES, :DEFAULT_NONCE_DIRECTIVES attr_reader :directives @@ -202,8 +216,9 @@ module ActionDispatch #:nodoc: end end - def build(context = nil, nonce = nil) - build_directives(context, nonce).compact.join("; ") + def build(context = nil, nonce = nil, nonce_directives = nil) + nonce_directives = DEFAULT_NONCE_DIRECTIVES if nonce_directives.nil? + build_directives(context, nonce, nonce_directives).compact.join("; ") end private @@ -226,10 +241,10 @@ module ActionDispatch #:nodoc: end end - def build_directives(context, nonce) + def build_directives(context, nonce, nonce_directives) @directives.map do |directive, sources| if sources.is_a?(Array) - if nonce && nonce_directive?(directive) + if nonce && nonce_directive?(directive, nonce_directives) "#{directive} #{build_directive(sources, context).join(' ')} 'nonce-#{nonce}'" else "#{directive} #{build_directive(sources, context).join(' ')}" @@ -264,8 +279,8 @@ module ActionDispatch #:nodoc: end end - def nonce_directive?(directive) - NONCE_DIRECTIVES.include?(directive) + def nonce_directive?(directive, nonce_directives) + nonce_directives.include?(directive) end end end diff --git a/actionpack/lib/action_dispatch/http/feature_policy.rb b/actionpack/lib/action_dispatch/http/feature_policy.rb new file mode 100644 index 0000000000..c09690f9fc --- /dev/null +++ b/actionpack/lib/action_dispatch/http/feature_policy.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/deep_dup" + +module ActionDispatch #:nodoc: + class FeaturePolicy + class Middleware + CONTENT_TYPE = "Content-Type" + POLICY = "Feature-Policy" + + def initialize(app) + @app = app + end + + def call(env) + request = ActionDispatch::Request.new(env) + _, headers, _ = response = @app.call(env) + + return response unless html_response?(headers) + return response if policy_present?(headers) + + if policy = request.feature_policy + headers[POLICY] = policy.build(request.controller_instance) + end + + if policy_empty?(policy) + headers.delete(POLICY) + end + + response + end + + private + def html_response?(headers) + if content_type = headers[CONTENT_TYPE] + /html/.match?(content_type) + end + end + + def policy_present?(headers) + headers[POLICY] + end + + def policy_empty?(policy) + policy&.directives&.empty? + end + end + + module Request + POLICY = "action_dispatch.feature_policy" + + def feature_policy + get_header(POLICY) + end + + def feature_policy=(policy) + set_header(POLICY, policy) + end + end + + MAPPINGS = { + self: "'self'", + none: "'none'", + }.freeze + + # List of available features can be found at + # https://github.com/WICG/feature-policy/blob/master/features.md#policy-controlled-features + DIRECTIVES = { + accelerometer: "accelerometer", + ambient_light_sensor: "ambient-light-sensor", + autoplay: "autoplay", + camera: "camera", + encrypted_media: "encrypted-media", + fullscreen: "fullscreen", + geolocation: "geolocation", + gyroscope: "gyroscope", + magnetometer: "magnetometer", + microphone: "microphone", + midi: "midi", + payment: "payment", + picture_in_picture: "picture-in-picture", + speaker: "speaker", + usb: "usb", + vibrate: "vibrate", + vr: "vr", + }.freeze + + private_constant :MAPPINGS, :DIRECTIVES + + attr_reader :directives + + def initialize + @directives = {} + yield self if block_given? + end + + def initialize_copy(other) + @directives = other.directives.deep_dup + end + + DIRECTIVES.each do |name, directive| + define_method(name) do |*sources| + if sources.first + @directives[directive] = apply_mappings(sources) + else + @directives.delete(directive) + end + end + end + + def build(context = nil) + build_directives(context).compact.join("; ") + end + + private + def apply_mappings(sources) + sources.map do |source| + case source + when Symbol + apply_mapping(source) + when String, Proc + source + else + raise ArgumentError, "Invalid HTTP feature policy source: #{source.inspect}" + end + end + end + + def apply_mapping(source) + MAPPINGS.fetch(source) do + raise ArgumentError, "Unknown HTTP feature policy source mapping: #{source.inspect}" + end + end + + def build_directives(context) + @directives.map do |directive, sources| + if sources.is_a?(Array) + "#{directive} #{build_directive(sources, context).join(' ')}" + elsif sources + directive + else + nil + end + end + end + + def build_directive(sources, context) + sources.map { |source| resolve_source(source, context) } + end + + def resolve_source(source, context) + case source + when String + source + when Symbol + source.to_s + when Proc + if context.nil? + raise RuntimeError, "Missing context for the dynamic feature policy source: #{source.inspect}" + else + context.instance_exec(&source) + end + else + raise RuntimeError, "Unexpected feature policy source: #{source.inspect}" + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/filter_parameters.rb b/actionpack/lib/action_dispatch/http/filter_parameters.rb index 7a7a493f64..7ad1ba3e0e 100644 --- a/actionpack/lib/action_dispatch/http/filter_parameters.rb +++ b/actionpack/lib/action_dispatch/http/filter_parameters.rb @@ -23,7 +23,7 @@ module ActionDispatch # change { file: { code: "xxxx"} } # # env["action_dispatch.parameter_filter"] = -> (k, v) do - # v.reverse! if k =~ /secret/i + # v.reverse! if k.match?(/secret/i) # end # => reverses the value to all keys matching /secret/i module FilterParameters diff --git a/actionpack/lib/action_dispatch/http/filter_redirect.rb b/actionpack/lib/action_dispatch/http/filter_redirect.rb index d780d5f793..3bd1f5109d 100644 --- a/actionpack/lib/action_dispatch/http/filter_redirect.rb +++ b/actionpack/lib/action_dispatch/http/filter_redirect.rb @@ -27,7 +27,7 @@ module ActionDispatch if String === filter location.include?(filter) elsif Regexp === filter - location =~ filter + location.match?(filter) end end end diff --git a/actionpack/lib/action_dispatch/http/mime_negotiation.rb b/actionpack/lib/action_dispatch/http/mime_negotiation.rb index a2cac49082..6bf4e652d3 100644 --- a/actionpack/lib/action_dispatch/http/mime_negotiation.rb +++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb @@ -62,13 +62,7 @@ module ActionDispatch def formats fetch_header("action_dispatch.request.formats") do |k| - params_readable = begin - parameters[:format] - rescue *RESCUABLE_MIME_FORMAT_ERRORS - false - end - - v = if params_readable + v = if params_readable? Array(Mime[parameters[:format]]) elsif use_accept_header && valid_accept_header accepts @@ -153,12 +147,22 @@ module ActionDispatch order.include?(Mime::ALL) ? format : nil end + def should_apply_vary_header? + !params_readable? && use_accept_header && valid_accept_header + end + private BROWSER_LIKE_ACCEPTS = /,\s*\*\/\*|\*\/\*\s*,/ + def params_readable? # :doc: + parameters[:format] + rescue *RESCUABLE_MIME_FORMAT_ERRORS + false + end + def valid_accept_header # :doc: (xhr? && (accept.present? || content_mime_type)) || - (accept.present? && accept !~ BROWSER_LIKE_ACCEPTS) + (accept.present? && !accept.match?(BROWSER_LIKE_ACCEPTS)) end def use_accept_header # :doc: diff --git a/actionpack/lib/action_dispatch/http/mime_type.rb b/actionpack/lib/action_dispatch/http/mime_type.rb index ed1d50f3b9..60b78c0582 100644 --- a/actionpack/lib/action_dispatch/http/mime_type.rb +++ b/actionpack/lib/action_dispatch/http/mime_type.rb @@ -202,7 +202,7 @@ module Mime # For an input of <tt>'application'</tt>, returns <tt>[Mime[:html], Mime[:js], # Mime[:xml], Mime[:yaml], Mime[:atom], Mime[:json], Mime[:rss], Mime[:url_encoded_form]</tt>. def parse_data_with_trailing_star(type) - Mime::SET.select { |m| m =~ type } + Mime::SET.select { |m| m.match?(type) } end # This method is opposite of register method. @@ -283,8 +283,14 @@ module Mime @synonyms.any? { |synonym| synonym.to_s =~ regexp } || @string =~ regexp end + def match?(mime_type) + return false unless mime_type + regexp = Regexp.new(Regexp.quote(mime_type.to_s)) + @synonyms.any? { |synonym| synonym.to_s.match?(regexp) } || @string.match?(regexp) + end + def html? - symbol == :html || @string =~ /html/ + (symbol == :html) || /html/.match?(@string) end def all?; false; end diff --git a/actionpack/lib/action_dispatch/http/request.rb b/actionpack/lib/action_dispatch/http/request.rb index 44f23940d3..54dbb536c1 100644 --- a/actionpack/lib/action_dispatch/http/request.rb +++ b/actionpack/lib/action_dispatch/http/request.rb @@ -23,6 +23,7 @@ module ActionDispatch include ActionDispatch::Http::FilterParameters include ActionDispatch::Http::URL include ActionDispatch::ContentSecurityPolicy::Request + include ActionDispatch::FeaturePolicy::Request include Rack::Request::Env autoload :Session, "action_dispatch/request/session" @@ -84,7 +85,7 @@ module ActionDispatch def controller_class_for(name) if name controller_param = name.underscore - const_name = "#{controller_param.camelize}Controller" + const_name = controller_param.camelize << "Controller" ActiveSupport::Dependencies.constantize(const_name) else PASS_NOT_FOUND @@ -264,7 +265,7 @@ module ActionDispatch # (case-insensitive), which may need to be manually added depending on the # choice of JavaScript libraries and frameworks. def xml_http_request? - get_header("HTTP_X_REQUESTED_WITH") =~ /XMLHttpRequest/i + /XMLHttpRequest/i.match?(get_header("HTTP_X_REQUESTED_WITH")) end alias :xhr? :xml_http_request? @@ -399,7 +400,7 @@ module ActionDispatch # True if the request came from localhost, 127.0.0.1, or ::1. def local? - LOCALHOST =~ remote_addr && LOCALHOST =~ remote_ip + LOCALHOST.match?(remote_addr) && LOCALHOST.match?(remote_ip) end def request_parameters=(params) diff --git a/actionpack/lib/action_dispatch/http/url.rb b/actionpack/lib/action_dispatch/http/url.rb index 3b0f6378ea..225ae0a497 100644 --- a/actionpack/lib/action_dispatch/http/url.rb +++ b/actionpack/lib/action_dispatch/http/url.rb @@ -133,7 +133,7 @@ module ActionDispatch end def named_host?(host) - IP_HOST_REGEXP !~ host + !IP_HOST_REGEXP.match?(host) end def normalize_protocol(protocol) diff --git a/actionpack/lib/action_dispatch/journey/route.rb b/actionpack/lib/action_dispatch/journey/route.rb index 4aee7a6f83..9184676801 100644 --- a/actionpack/lib/action_dispatch/journey/route.rb +++ b/actionpack/lib/action_dispatch/journey/route.rb @@ -148,7 +148,7 @@ module ActionDispatch end def glob? - !path.spec.grep(Nodes::Star).empty? + path.spec.any?(Nodes::Star) end def dispatcher? diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb index 1f3bf7fca6..9d94d94ffb 100644 --- a/actionpack/lib/action_dispatch/middleware/cookies.rb +++ b/actionpack/lib/action_dispatch/middleware/cookies.rb @@ -346,28 +346,6 @@ module ActionDispatch @cookies.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join "; " end - def handle_options(options) # :nodoc: - if options[:expires].respond_to?(:from_now) - options[:expires] = options[:expires].from_now - end - - options[:path] ||= "/" - - if options[:domain] == :all || options[:domain] == "all" - # If there is a provided tld length then we use it otherwise default domain regexp. - domain_regexp = options[:tld_length] ? /([^.]+\.?){#{options[:tld_length]}}$/ : DOMAIN_REGEXP - - # If host is not ip and matches domain regexp. - # (ip confirms to domain regexp so we explicitly check for ip) - options[:domain] = if (request.host !~ /^[\d.]+$/) && (request.host =~ domain_regexp) - ".#{$&}" - end - elsif options[:domain].is_a? Array - # If host matches one of the supplied domains without a dot in front of it. - options[:domain] = options[:domain].find { |domain| request.host.include? domain.sub(/^\./, "") } - end - end - # Sets the cookie named +name+. The second argument may be the cookie's # value or a hash of options as documented above. def []=(name, options) @@ -447,6 +425,28 @@ module ActionDispatch def write_cookie?(cookie) request.ssl? || !cookie[:secure] || always_write_cookie end + + def handle_options(options) + if options[:expires].respond_to?(:from_now) + options[:expires] = options[:expires].from_now + end + + options[:path] ||= "/" + + if options[:domain] == :all || options[:domain] == "all" + # If there is a provided tld length then we use it otherwise default domain regexp. + domain_regexp = options[:tld_length] ? /([^.]+\.?){#{options[:tld_length]}}$/ : DOMAIN_REGEXP + + # If host is not ip and matches domain regexp. + # (ip confirms to domain regexp so we explicitly check for ip) + options[:domain] = if !request.host.match?(/^[\d.]+$/) && (request.host =~ domain_regexp) + ".#{$&}" + end + elsif options[:domain].is_a? Array + # If host matches one of the supplied domains without a dot in front of it. + options[:domain] = options[:domain].find { |domain| request.host.include? domain.sub(/^\./, "") } + end + end end class AbstractCookieJar # :nodoc: diff --git a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb index f8937a2faf..e546d1c11f 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb @@ -137,9 +137,7 @@ module ActionDispatch return unless logger exception = wrapper.exception - - trace = wrapper.application_trace - trace = wrapper.framework_trace if trace.empty? + trace = wrapper.exception_trace ActiveSupport::Deprecation.silence do message = [] diff --git a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb index 2da0ef9600..e4a2a51c57 100644 --- a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb +++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb @@ -36,18 +36,23 @@ module ActionDispatch "ActionView::Template::Error" ] + cattr_accessor :silent_exceptions, default: [ + "ActionController::RoutingError" + ] + attr_reader :backtrace_cleaner, :exception, :wrapped_causes, :line_number, :file def initialize(backtrace_cleaner, exception) @backtrace_cleaner = backtrace_cleaner @exception = exception + @exception_class_name = @exception.class.name @wrapped_causes = wrapped_causes_for(exception, backtrace_cleaner) expand_backtrace if exception.is_a?(SyntaxError) || exception.cause.is_a?(SyntaxError) end def unwrapped_exception - if wrapper_exceptions.include?(exception.class.to_s) + if wrapper_exceptions.include?(@exception_class_name) exception.cause else exception @@ -55,13 +60,19 @@ module ActionDispatch end def rescue_template - @@rescue_templates[@exception.class.name] + @@rescue_templates[@exception_class_name] end def status_code self.class.status_code_for_exception(unwrapped_exception.class.name) end + def exception_trace + trace = application_trace + trace = framework_trace if trace.empty? && !silent_exceptions.include?(@exception_class_name) + trace + end + def application_trace clean_backtrace(:silent) end diff --git a/actionpack/lib/action_dispatch/middleware/ssl.rb b/actionpack/lib/action_dispatch/middleware/ssl.rb index 00902ede21..237eccf45f 100644 --- a/actionpack/lib/action_dispatch/middleware/ssl.rb +++ b/actionpack/lib/action_dispatch/middleware/ssl.rb @@ -13,7 +13,7 @@ module ActionDispatch # # Requests can opt-out of redirection with +exclude+: # - # config.ssl_options = { redirect: { exclude: -> request { request.path =~ /healthcheck/ } } } + # config.ssl_options = { redirect: { exclude: -> request { /healthcheck/.match?(request.path) } } } # # Cookies will not be flagged as secure for excluded requests. # @@ -126,7 +126,7 @@ module ActionDispatch [ @redirect.fetch(:status, redirection_status(request)), { "Content-Type" => "text/html", "Location" => https_location_for(request) }, - @redirect.fetch(:body, []) ] + (@redirect[:body] || []) ] end def redirection_status(request) diff --git a/actionpack/lib/action_dispatch/middleware/static.rb b/actionpack/lib/action_dispatch/middleware/static.rb index 1f2f7757a3..eddcdbaeac 100644 --- a/actionpack/lib/action_dispatch/middleware/static.rb +++ b/actionpack/lib/action_dispatch/middleware/static.rb @@ -32,18 +32,13 @@ module ActionDispatch return false unless ::Rack::Utils.valid_path? path path = ::Rack::Utils.clean_path_info path - paths = [path, "#{path}#{ext}", "#{path}/#{@index}#{ext}"] + return ::Rack::Utils.escape_path(path).b if file_readable?(path) - if match = paths.detect { |p| - path = File.join(@root, p.b) - begin - File.file?(path) && File.readable?(path) - rescue SystemCallError - false - end - } - return ::Rack::Utils.escape_path(match).b - end + path_with_ext = path + ext + return ::Rack::Utils.escape_path(path_with_ext).b if file_readable?(path_with_ext) + + path << "/" << @index << ext + return ::Rack::Utils.escape_path(path).b if file_readable?(path) end def call(env) @@ -83,11 +78,11 @@ module ActionDispatch end def gzip_encoding_accepted?(request) - request.accept_encoding.any? { |enc, quality| enc =~ /\bgzip\b/i } + request.accept_encoding.any? { |enc, quality| /\bgzip\b/i.match?(enc) } end def gzip_file_path(path) - can_gzip_mime = content_type(path) =~ /\A(?:text\/|application\/javascript)/ + can_gzip_mime = /\A(?:text\/|application\/javascript)/.match?(content_type(path)) gzip_path = "#{path}.gz" if can_gzip_mime && File.exist?(File.join(@root, ::Rack::Utils.unescape_path(gzip_path))) gzip_path @@ -95,6 +90,12 @@ module ActionDispatch false end end + + def file_readable?(path) + file_path = File.join(@root, path.b) + File.file?(file_path) && File.readable?(file_path) + rescue SystemCallError + end end # This middleware will attempt to return the contents of a file's body from diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 66f90980b9..2e09aed41d 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -54,11 +54,5 @@ module ActionDispatch ActionDispatch.test_app = app end - - initializer "action_dispatch.system_tests" do |app| - ActiveSupport.on_load(:action_dispatch_system_test_case) do - include app.routes.url_helpers - end - end end end diff --git a/actionpack/lib/action_dispatch/routing/inspector.rb b/actionpack/lib/action_dispatch/routing/inspector.rb index 6e40a18009..bf286c299d 100644 --- a/actionpack/lib/action_dispatch/routing/inspector.rb +++ b/actionpack/lib/action_dispatch/routing/inspector.rb @@ -94,7 +94,7 @@ module ActionDispatch if filter @routes.select do |route| route_wrapper = RouteWrapper.new(route) - filter.any? { |default, value| route_wrapper.send(default) =~ value } + filter.any? { |default, value| value.match?(route_wrapper.send(default)) } end else @routes diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index d1100089b1..da95e14cbb 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -112,7 +112,7 @@ module ActionDispatch end def self.optional_format?(path, format) - format != false && path !~ OPTIONAL_FORMAT_REGEX + format != false && !path.match?(OPTIONAL_FORMAT_REGEX) end def initialize(set:, ast:, controller:, default_action:, to:, formatted:, via:, options_constraints:, anchor:, scope_params:, options:) @@ -367,7 +367,7 @@ module ActionDispatch def translate_controller(controller) return controller if Regexp === controller - return controller.to_s if controller =~ /\A[a-z_0-9][a-z_0-9\/]*\z/ + return controller.to_s if /\A[a-z_0-9][a-z_0-9\/]*\z/.match?(controller) yield end @@ -403,7 +403,7 @@ module ActionDispatch # for root cases, where the latter is the correct one. def self.normalize_path(path) path = Journey::Router::Utils.normalize_path(path) - path.gsub!(%r{/(\(+)/?}, '\1/') unless path =~ %r{^/(\(+[^)]+\)){1,}$} + path.gsub!(%r{/(\(+)/?}, '\1/') unless %r{^/(\(+[^)]+\)){1,}$}.match?(path) path end @@ -996,7 +996,7 @@ module ActionDispatch # # Requests to routes can be constrained based on specific criteria: # - # constraints(-> (req) { req.env["HTTP_USER_AGENT"] =~ /iPhone/ }) do + # constraints(-> (req) { /iPhone/.match?(req.env["HTTP_USER_AGENT"]) }) do # resources :iphones # end # @@ -1006,7 +1006,7 @@ module ActionDispatch # # class Iphone # def self.matches?(request) - # request.env["HTTP_USER_AGENT"] =~ /iPhone/ + # /iPhone/.match?(request.env["HTTP_USER_AGENT"]) # end # end # @@ -1833,7 +1833,7 @@ module ActionDispatch # and return nil in case it isn't. Otherwise, we pass the invalid name # forward so the underlying router engine treats it and raises an exception. if as.nil? - candidate unless candidate !~ /\A[_a-z]/i || has_named_route?(candidate) + candidate unless !candidate.match?(/\A[_a-z]/i) || has_named_route?(candidate) else candidate end @@ -1887,7 +1887,7 @@ module ActionDispatch options_constraints = options.delete(:constraints) || {} path_types = paths.group_by(&:class) - path_types.fetch(String, []).each do |_path| + (path_types[String] || []).each do |_path| route_options = options.dup if _path && option_path raise ArgumentError, "Ambiguous route definition. Both :path and the route path were specified as strings." @@ -1896,7 +1896,7 @@ module ActionDispatch decomposed_match(_path, controller, route_options, _path, to, via, formatted, anchor, options_constraints) end - path_types.fetch(Symbol, []).each do |action| + (path_types[Symbol] || []).each do |action| route_options = options.dup decomposed_match(action, controller, route_options, option_path, to, via, formatted, anchor, options_constraints) end @@ -1916,7 +1916,7 @@ module ActionDispatch end def using_match_shorthand?(path) - path =~ %r{^/?[-\w]+/[-\w/]+$} + %r{^/?[-\w]+/[-\w/]+$}.match?(path) end def decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb index 5b35b68c44..db8c54ba84 100644 --- a/actionpack/lib/action_dispatch/routing/route_set.rb +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -836,7 +836,7 @@ module ActionDispatch def recognize_path(path, environment = {}) method = (environment[:method] || "GET").to_s.upcase - path = Journey::Router::Utils.normalize_path(path) unless path =~ %r{://} + path = Journey::Router::Utils.normalize_path(path) unless %r{://}.match?(path) extras = environment[:extras] || {} begin diff --git a/actionpack/lib/action_dispatch/system_test_case.rb b/actionpack/lib/action_dispatch/system_test_case.rb index a7fb5fa330..aae96975c7 100644 --- a/actionpack/lib/action_dispatch/system_test_case.rb +++ b/actionpack/lib/action_dispatch/system_test_case.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -gem "capybara", ">= 2.15" +gem "capybara", ">= 3.26" require "capybara/dsl" require "capybara/minitest" +require "selenium/webdriver" require "action_controller" require "action_dispatch/system_testing/driver" require "action_dispatch/system_testing/browser" @@ -158,12 +159,33 @@ module ActionDispatch driven_by :selenium - def url_options # :nodoc: - default_url_options.merge(host: Capybara.app_host) - end + private + def url_helpers + @url_helpers ||= + if ActionDispatch.test_app + Class.new do + include ActionDispatch.test_app.routes.url_helpers - ActiveSupport.run_load_hooks(:action_dispatch_system_test_case, self) - end + def url_options + default_url_options.reverse_merge(host: Capybara.app_host || Capybara.current_session.server_url) + end + end.new + end + end - SystemTestCase.start_application + def method_missing(name, *args, &block) + if url_helpers.respond_to?(name) + url_helpers.public_send(name, *args, &block) + else + super + end + end + + def respond_to_missing?(name, include_private = false) + url_helpers.respond_to?(name) + end + end end + +ActiveSupport.run_load_hooks :action_dispatch_system_test_case, ActionDispatch::SystemTestCase +ActionDispatch::SystemTestCase.start_application diff --git a/actionpack/lib/action_dispatch/system_testing/browser.rb b/actionpack/lib/action_dispatch/system_testing/browser.rb index c34907b6cb..91f332be13 100644 --- a/actionpack/lib/action_dispatch/system_testing/browser.rb +++ b/actionpack/lib/action_dispatch/system_testing/browser.rb @@ -39,6 +39,29 @@ module ActionDispatch end end + # driver_path can be configured as a proc. The webdrivers gem uses this + # proc to update web drivers. Running this proc early allows us to only + # update the webdriver once and avoid race conditions when using + # parallel tests. + def preload + case type + when :chrome + if ::Selenium::WebDriver::Service.respond_to? :driver_path= + ::Selenium::WebDriver::Chrome::Service.driver_path&.call + else + # Selenium <= v3.141.0 + ::Selenium::WebDriver::Chrome.driver_path + end + when :firefox + if ::Selenium::WebDriver::Service.respond_to? :driver_path= + ::Selenium::WebDriver::Firefox::Service.driver_path&.call + else + # Selenium <= v3.141.0 + ::Selenium::WebDriver::Firefox.driver_path + end + end + end + private def headless_chrome_browser_options capabilities.args << "--headless" diff --git a/actionpack/lib/action_dispatch/system_testing/driver.rb b/actionpack/lib/action_dispatch/system_testing/driver.rb index 25a09dd918..15943a55ea 100644 --- a/actionpack/lib/action_dispatch/system_testing/driver.rb +++ b/actionpack/lib/action_dispatch/system_testing/driver.rb @@ -9,6 +9,8 @@ module ActionDispatch @screen_size = options[:screen_size] @options = options[:options] @capabilities = capabilities + + @browser.preload end def use diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb index 056ce51a61..cba053ee4c 100644 --- a/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb @@ -9,10 +9,16 @@ module ActionDispatch # # +take_screenshot+ can be used at any point in your system tests to take # a screenshot of the current state. This can be useful for debugging or - # automating visual testing. + # automating visual testing. You can take multiple screenshots per test + # to investigate changes at different points during your test. These will be + # named with a sequential prefix (or 'failed' for failing tests) # # The screenshot will be displayed in your console, if supported. # + # You can set the +RAILS_SYSTEM_TESTING_SCREENSHOT_HTML+ environment variable to + # save the HTML from the page that is being screenhoted so you can investigate the + # elements on the page at the time of the screenshot + # # You can set the +RAILS_SYSTEM_TESTING_SCREENSHOT+ environment variable to # control the output. Possible values are: # * [+simple+ (default)] Only displays the screenshot path. @@ -22,6 +28,8 @@ module ActionDispatch # * [+artifact+] Display the screenshot in the terminal, using the terminal # artifact format (https://buildkite.github.io/terminal-to-html/inline-images/). def take_screenshot + increment_unique + save_html if save_html? save_image puts display_image end @@ -38,17 +46,48 @@ module ActionDispatch end private + attr_accessor :_screenshot_counter + + def save_html? + ENV["RAILS_SYSTEM_TESTING_SCREENSHOT_HTML"] == "1" + end + + def increment_unique + @_screenshot_counter ||= 0 + @_screenshot_counter += 1 + end + + def unique + failed? ? "failures" : (_screenshot_counter || 0).to_s + end + def image_name - name = method_name[0...225] - failed? ? "failures_#{name}" : name + name = "#{unique}_#{method_name}" + name[0...225] end def image_path - @image_path ||= absolute_image_path.to_s + absolute_image_path.to_s + end + + def html_path + absolute_html_path.to_s + end + + def absolute_path + Rails.root.join("tmp/screenshots/#{image_name}") end def absolute_image_path - Rails.root.join("tmp/screenshots/#{image_name}.png") + "#{absolute_path}.png" + end + + def absolute_html_path + "#{absolute_path}.html" + end + + def save_html + page.save_page(absolute_html_path) end def save_image @@ -66,7 +105,8 @@ module ActionDispatch end def display_image - message = +"[Screenshot]: #{image_path}\n" + message = +"[Screenshot Image]: #{image_path}\n" + message << +"[Screenshot HTML]: #{html_path}\n" if save_html? case output_type when "artifact" diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb index 20f6a7634f..30dc21ebb9 100644 --- a/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb @@ -4,15 +4,12 @@ module ActionDispatch module SystemTesting module TestHelpers module SetupAndTeardown # :nodoc: - DEFAULT_HOST = "http://127.0.0.1" - def host!(host) - Capybara.app_host = host - end + ActiveSupport::Deprecation.warn \ + "ActionDispatch::SystemTestCase#host! is deprecated with no replacement. " \ + "Set Capybara.app_host directly or rely on Capybara's default host." - def before_setup - host! DEFAULT_HOST - super + Capybara.app_host = host end def before_teardown diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index bb8b43ad4d..9e7b4301a8 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -3,7 +3,6 @@ require "stringio" require "uri" require "active_support/core_ext/kernel/singleton_class" -require "active_support/core_ext/object/try" require "rack/test" require "minitest" @@ -50,11 +49,16 @@ module ActionDispatch # Follow a single redirect response. If the last response was not a # redirect, an exception will be raised. Otherwise, the redirect is - # performed on the location header. Any arguments are passed to the - # underlying call to `get`. + # performed on the location header. If the redirection is a 307 redirect, + # the same HTTP verb will be used when redirecting, otherwise a GET request + # will be performed. Any arguments are passed to the + # underlying request. def follow_redirect!(**args) raise "not a redirect! #{status} #{status_message}" unless redirect? - get(response.location, **args) + + method = response.status == 307 ? request.method.downcase : :get + public_send(method, response.location, **args) + status end end |