diff --git a/Gemfile b/Gemfile index a21adecbe..b7177cfc7 100644 --- a/Gemfile +++ b/Gemfile @@ -41,7 +41,7 @@ gem "elasticsearch", "~> 7.13.3" gem "active_attr", "~> 0.16.0" # Factories for test database data -gem "factory_bot", "~> 6.3.0" +gem "factory_bot", "~> 6.4.0" # Programmatically generate Rails session cookies. gem "rails_compatible_cookies_utils", "~> 0.1.0" @@ -51,12 +51,14 @@ gem "addressable", "~> 2.8.0" # Browser/JavaScript integration tests gem "capybara", "~> 3.32" -gem "selenium-webdriver", "~> 3.141" -gem "capybara-chromedriver-logger", "~> 0.3.0" +gem "selenium-webdriver", "~> 4.15" # Take screenshots on capybara test failures gem "capybara-screenshot", "~> 1.0.22" +# Adds support for `assert_text` for shadow DOM tests. +gem "capybara-shadowdom", "~> 0.3.0" + # HTML or XML parsing gem "nokogiri", "~> 1.14" diff --git a/Gemfile.lock b/Gemfile.lock index b6ecf68c7..2a7d36162 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,17 +1,18 @@ GEM remote: https://rubygems.org/ specs: - actionpack (7.1.1) - actionview (= 7.1.1) - activesupport (= 7.1.1) + actionpack (7.1.2) + actionview (= 7.1.2) + activesupport (= 7.1.2) nokogiri (>= 1.8.5) + racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actionview (7.1.1) - activesupport (= 7.1.1) + actionview (7.1.2) + activesupport (= 7.1.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -20,13 +21,13 @@ GEM actionpack (>= 3.0.2, < 7.2) activemodel (>= 3.0.2, < 7.2) activesupport (>= 3.0.2, < 7.2) - activemodel (7.1.1) - activesupport (= 7.1.1) - activerecord (7.1.1) - activemodel (= 7.1.1) - activesupport (= 7.1.1) + activemodel (7.1.2) + activesupport (= 7.1.2) + activerecord (7.1.2) + activemodel (= 7.1.2) + activesupport (= 7.1.2) timeout (>= 0.4.0) - activesupport (7.1.1) + activesupport (7.1.2) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -42,7 +43,7 @@ GEM ast (2.4.2) awesome_print (1.9.2) base64 (0.2.0) - bcrypt (3.1.19) + bcrypt (3.1.20) bigdecimal (3.1.4) builder (3.2.4) capybara (3.39.2) @@ -54,14 +55,12 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - capybara-chromedriver-logger (0.3.0) - capybara - colorize capybara-screenshot (1.0.26) capybara (>= 1.0, < 4) launchy - childprocess (3.0.0) - colorize (1.1.0) + capybara-shadowdom (0.3.0) + capybara + childprocess (4.1.0) concurrent-ruby (1.2.2) connection_pool (2.4.1) crass (1.0.6) @@ -79,7 +78,7 @@ GEM erubi (1.12.0) ethon (0.16.0) ffi (>= 1.15.0) - factory_bot (6.3.0) + factory_bot (6.4.0) activesupport (>= 5.0.0) faker (3.2.2) i18n (>= 1.8.11, < 2) @@ -114,7 +113,7 @@ GEM language_server-protocol (3.17.0.3) launchy (2.5.2) addressable (~> 2.8) - loofah (2.21.4) + loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) matrix (0.4.2) @@ -139,12 +138,12 @@ GEM timeout net-smtp (0.4.0) net-protocol - nokogiri (1.15.4) + nokogiri (1.15.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.15.4-aarch64-linux) + nokogiri (1.15.5-aarch64-linux) racc (~> 1.4) - nokogiri (1.15.4-x86_64-linux) + nokogiri (1.15.5-x86_64-linux) racc (~> 1.4) oj (3.16.1) parallel (1.23.0) @@ -153,7 +152,7 @@ GEM racc path_expander (1.1.1) pg (1.5.4) - public_suffix (5.0.3) + public_suffix (5.0.4) racc (1.7.3) rack (3.0.8) rack-session (2.0.0) @@ -189,16 +188,18 @@ GEM ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) - selenium-webdriver (3.142.7) - childprocess (>= 0.5, < 4.0) - rubyzip (>= 1.2.2) + selenium-webdriver (4.15.0) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) thor (1.3.0) timeout (0.4.1) - typhoeus (1.4.0) + typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) + websocket (1.2.10) xpath (3.2.0) nokogiri (~> 1.8) zonebie (0.6.1) @@ -216,13 +217,13 @@ DEPENDENCIES awesome_print (~> 1.9.2) bcrypt (~> 3.1.12) capybara (~> 3.32) - capybara-chromedriver-logger (~> 0.3.0) capybara-screenshot (~> 1.0.22) + capybara-shadowdom (~> 0.3.0) childprocess concurrent-ruby (~> 1.2.0) elasticsearch (~> 7.13.3) encryptor (~> 3.0.0) - factory_bot (~> 6.3.0) + factory_bot (~> 6.4.0) faker (~> 3.0) ice_nine (~> 0.11.2) minitest (~> 5.20.0) @@ -239,7 +240,7 @@ DEPENDENCIES rainbow (~> 3.1.1) rubocop (~> 1.4) rubocop-minitest (~> 0.33.0) - selenium-webdriver (~> 3.141) + selenium-webdriver (~> 4.15) thor (~> 1.3.0) typhoeus (~> 1.4.0) zonebie (~> 0.6.1) diff --git a/src/api-umbrella/example-website/assets/javascripts/signup_embed.js b/src/api-umbrella/example-website/assets/javascripts/signup_embed.js index 3f8d17196..744da8fe8 100644 --- a/src/api-umbrella/example-website/assets/javascripts/signup_embed.js +++ b/src/api-umbrella/example-website/assets/javascripts/signup_embed.js @@ -209,6 +209,7 @@ const modalTemplate = ` const containerEl = document.querySelector(options.containerSelector); containerEl.textContent = ""; const containerContentEl = document.createElement("div"); +containerContentEl.className = "api-umbrella-signup-embed-content-container"; containerEl.appendChild(containerContentEl); const containerShadowRootEl = containerContentEl.attachShadow({ mode: "open" }); @@ -217,12 +218,14 @@ const containerShadowRootEl = containerContentEl.attachShadow({ mode: "open" }); let recaptchaV2El; if (options.recaptchaV2SiteKey) { recaptchaV2El = document.createElement("div"); + recaptchaV2El.className = "api-umbrella-signup-embed-recaptcha-v2"; recaptchaV2El.style = "visibility: hidden;"; containerEl.appendChild(recaptchaV2El); } let recaptchaV3El; if (options.recaptchaV3SiteKey) { recaptchaV3El = document.createElement("div"); + recaptchaV3El.className = "api-umbrella-signup-embed-recaptcha-v3"; recaptchaV3El.style = "visibility: hidden;"; containerEl.appendChild(recaptchaV3El); } diff --git a/src/api-umbrella/web-app/actions/v1/users.lua b/src/api-umbrella/web-app/actions/v1/users.lua index 69b433080..529bd3110 100644 --- a/src/api-umbrella/web-app/actions/v1/users.lua +++ b/src/api-umbrella/web-app/actions/v1/users.lua @@ -260,7 +260,7 @@ function _M.create(self) user_params["registration_recaptcha_v2_success"] = result["success"] user_params["registration_recaptcha_v2_error_codes"] = result["error-codes"] elseif recaptcha_err then - ngx.log(ngx.WARN, "reCAPTCHA v2 error: ", recaptcha_err) + ngx.log(ngx.ERR, "reCAPTCHA v2 error: ", recaptcha_err) end end @@ -272,7 +272,7 @@ function _M.create(self) user_params["registration_recaptcha_v3_action"] = result["action"] user_params["registration_recaptcha_v3_error_codes"] = result["error-codes"] elseif recaptcha_err then - ngx.log(ngx.WARN, "reCAPTCHA v2 error: ", recaptcha_err) + ngx.log(ngx.ERR, "reCAPTCHA v2 error: ", recaptcha_err) end end diff --git a/tasks/install-system-build-dependencies b/tasks/install-system-build-dependencies index 9f207c537..2ca39809d 100755 --- a/tasks/install-system-build-dependencies +++ b/tasks/install-system-build-dependencies @@ -70,19 +70,11 @@ if [ "${INSTALL_TEST_DEPENDENCIES:-}" == "true" ]; then rm "/tmp/chromedriver.zip" else # For ARM platforms, Chrome stable doesn't exist, so use Chromium - # instead. chromedriver also doesn't exist, so use Electron's ARM builds # instead. - CHROMIUM_VERSION="115.*" - CHROMEDRIVER_VERSION="25.6.0" - printf "Package: chromium*\nPin: version %s\nPin-Priority: 999\n" "$CHROMIUM_VERSION" > /etc/apt/preferences.d/chromium apt-get update - apt-get -y --no-install-recommends install chromium curl unzip + apt-get -y --no-install-recommends install chromium chromium-driver curl unzip chromium --version - - curl -fsSL -o "/tmp/chromedriver-v${CHROMEDRIVER_VERSION}-linux.zip" "https://github.com/electron/electron/releases/download/v${CHROMEDRIVER_VERSION}/chromedriver-v${CHROMEDRIVER_VERSION}-linux-${TARGETARCH}.zip" - unzip -o -d /usr/local/bin "/tmp/chromedriver-v${CHROMEDRIVER_VERSION}-linux.zip" chromedriver chromedriver --version - rm "/tmp/chromedriver-v${CHROMEDRIVER_VERSION}-linux.zip" fi fi fi diff --git a/test/apis/v1/users/test_create.rb b/test/apis/v1/users/test_create.rb index 551d44007..7e4be0f10 100644 --- a/test/apis/v1/users/test_create.rb +++ b/test/apis/v1/users/test_create.rb @@ -218,14 +218,14 @@ def test_captures_and_returns_requester_details_as_admin assert_equal("foo", data["user"]["registration_user_agent"]) assert_equal("http://example.com/foo", data["user"]["registration_referer"]) assert_equal("http://example.com", data["user"]["registration_origin"]) - assert_equal(@@api_user.id, data["user"]["registration_key_creator_api_user_id"]) + assert_equal(api_user.id, data["user"]["registration_key_creator_api_user_id"]) user = ApiUser.find(data["user"]["id"]) assert_equal(IPAddr.new("1.2.3.4"), user.registration_ip) assert_equal("foo", user.registration_user_agent) assert_equal("http://example.com/foo", user.registration_referer) assert_equal("http://example.com", user.registration_origin) - assert_equal(@@api_user.id, user.registration_key_creator_api_user_id) + assert_equal(api_user.id, user.registration_key_creator_api_user_id) end def test_captures_does_not_return_requester_details_as_non_admin diff --git a/test/apis/v1/users/test_index.rb b/test/apis/v1/users/test_index.rb index 03f6d1c78..d60ad86a7 100644 --- a/test/apis/v1/users/test_index.rb +++ b/test/apis/v1/users/test_index.rb @@ -25,6 +25,7 @@ def test_response_fields "foo" => "bar", }, :registration_ip => "127.0.0.10", + :registration_key_creator_api_user_id => api_user.id, :registration_origin => "http://example.com", :registration_referer => "http://example.com/foo", :registration_source => "test", @@ -68,6 +69,7 @@ def test_response_fields assert_equal({ "foo" => "bar" }, record_data.fetch("metadata")) assert_equal("foo: bar", record_data.fetch("metadata_yaml_string")) assert_equal("127.0.0.10", record_data.fetch("registration_ip")) + assert_equal(api_user.id, record_data.fetch("registration_key_creator_api_user_id")) assert_equal("http://example.com", record_data.fetch("registration_origin")) assert_equal("http://example.com/foo", record_data.fetch("registration_referer")) assert_equal("test", record_data.fetch("registration_source")) @@ -124,6 +126,7 @@ def test_empty_response_fields assert_equal(false, record_data.fetch("email_verified")) assert_equal(true, record_data.fetch("enabled")) assert_nil(record_data.fetch("registration_ip")) + assert_nil(record_data.fetch("registration_key_creator_api_user_id")) assert_nil(record_data.fetch("registration_origin")) assert_nil(record_data.fetch("registration_referer")) assert_nil(record_data.fetch("registration_source")) @@ -285,6 +288,7 @@ def assert_base_record_fields(record_data) "metadata", "metadata_yaml_string", "registration_ip", + "registration_key_creator_api_user_id", "registration_origin", "registration_referer", "registration_source", diff --git a/test/apis/v1/users/test_show.rb b/test/apis/v1/users/test_show.rb index 450f00287..7cc7ce3e1 100644 --- a/test/apis/v1/users/test_show.rb +++ b/test/apis/v1/users/test_show.rb @@ -38,6 +38,7 @@ def test_user_response "metadata", "metadata_yaml_string", "registration_ip", + "registration_key_creator_api_user_id", "registration_origin", "registration_referer", "registration_source", diff --git a/test/static_site/test_signup.rb b/test/static_site/test_signup.rb index 49d0f0d73..2dd859fd2 100644 --- a/test/static_site/test_signup.rb +++ b/test/static_site/test_signup.rb @@ -16,19 +16,44 @@ def test_submission visit "/signup/" assert_text("API Key Signup") - fill_in "First Name", :with => "Foo" - fill_in "Last Name", :with => "Bar" - fill_in "Email", :with => "foo@example.com" - check "I have read and agree to the terms and conditions." - click_button "Signup" - - assert_text("Your API key for foo@example.com is:") - - user = ApiUser.order(:created_at => :asc).last - assert(user) - assert(user.api_key) - assert_equal("foo@example.com", user.email) - assert_text(user.api_key) + # Because these elements are inside the shadow DOM, testing is a bit harder + # with Capybara, so we can't rely on the normal `fill_in` usage currently, + # so that's why this testing is a bit more manual. + within find("#api_umbrella_signup .api-umbrella-signup-embed-content-container").shadow_root do + first_name_input = find("input[name='user[first_name]']") + first_name_input.set "Foo" + first_name_label = find("label[for='#{first_name_input[:id]}']") + assert_equal("First Name *", first_name_label.text) + + last_name_input = find("input[name='user[last_name]']") + last_name_input.set "Bar" + last_name_label = find("label[for='#{last_name_input[:id]}']") + assert_equal("Last Name *", last_name_label.text) + + email_input = find("input[name='user[email]']") + email_input.set "foo@example.com" + email_label = find("label[for='#{email_input[:id]}']") + assert_equal("Email *", email_label.text) + + terms_input = find("input[name='user[terms_and_conditions]']") + terms_input.click + terms_label = find("label[for='#{terms_input[:id]}']") + assert_equal("I have read and agree to the terms and conditions.", terms_label.text) + + submit_button = find("button[type=submit]") + assert_equal("Signup", submit_button.text) + submit_button.click + + assert_text("Your API key for foo@example.com is:") + + user = ApiUser.order(:created_at => :asc).last + assert(user) + assert(user.api_key) + assert_equal("Foo", user.first_name) + assert_equal("Bar", user.last_name) + assert_equal("foo@example.com", user.email) + assert_text(user.api_key) + end messages = sent_emails assert_equal(1, messages.fetch("total")) diff --git a/test/support/capybara.rb b/test/support/capybara.rb index e6b32ba6d..bdba686c8 100644 --- a/test/support/capybara.rb +++ b/test/support/capybara.rb @@ -1,4 +1,5 @@ require "capybara/minitest" +require "capybara/shadowdom" require "capybara-screenshot/minitest" require "open3" require "support/api_umbrella_test_helpers/admin_auth" @@ -30,44 +31,57 @@ def capybara_register_driver(driver_name, options = {}) :args => service_args, })) - driver_options = Selenium::WebDriver::Chrome::Options.new - driver_options.args << "--headless" + driver_options = Selenium::WebDriver::Chrome::Options.new.tap do |opts| + opts.add_argument "--headless" - # Allow connections to our self-signed SSL localhost test server. - driver_options.args << "--allow-insecure-localhost" + # Allow connections to our self-signed SSL localhost test server. + opts.add_argument "--allow-insecure-localhost" - # Use /tmp instead of /dev/shm for Docker environments where /dev/shm is - # too small: - # https://github.com/GoogleChrome/puppeteer/blob/v1.10.0/docs/troubleshooting.md#tips - driver_options.args << "--disable-dev-shm-usage" + # Use /tmp instead of /dev/shm for Docker environments where /dev/shm is + # too small: + # https://github.com/GoogleChrome/puppeteer/blob/v1.10.0/docs/troubleshooting.md#tips + opts.add_argument "--disable-dev-shm-usage" - # Use a static user agent for some session tests. - driver_options.args << "--user-agent=#{ApiUmbrellaTestHelpers::AdminAuth::STATIC_USER_AGENT}" + # Use a static user agent for some session tests. + opts.add_argument "--user-agent=#{ApiUmbrellaTestHelpers::AdminAuth::STATIC_USER_AGENT}" - # Allow for usage in Docker. - driver_options.args << "--disable-setuid-sandbox" - driver_options.args << "--no-sandbox" + # Allow for usage in Docker. + opts.add_argument "--disable-setuid-sandbox" + opts.add_argument "--no-sandbox" - # Set the Accept-Language header used in tests. - if options[:lang] - driver_options.args << "--accept-lang=#{options[:lang]}" - end + # Set the Accept-Language header used in tests. + if options[:lang] + opts.add_argument "--accept-lang=#{options[:lang]}" + end - # Set download path for Chrome >= 77 - driver_options.add_preference(:download, :default_directory => ApiUmbrellaTestHelpers::Downloads::DOWNLOADS_ROOT) + # Set download path for Chrome >= 77 + opts.add_preference(:download, :default_directory => ApiUmbrellaTestHelpers::Downloads::DOWNLOADS_ROOT) - capabilities = Capybara::Chromedriver::Logger.build_capabilities( - :chromeOptions => { - :args => ["headless"], - }, - ) + # Enable web socket support for BiDi LogInspector support below. + opts.web_socket_url = true + end - driver = Capybara::Selenium::Driver.new(app, - :browser => :chrome, - :service => service, - :options => driver_options, - :desired_capabilities => capabilities) - driver.resize_window_to(driver.current_window_handle, 1200, 4000) + driver = Capybara::Selenium::Driver.new(app, browser: :chrome, options: driver_options, service: service) + driver.resize_window_to(driver.current_window_handle, 1024, 4000) + + # Keep track of console log output so we can error if JavaScript errors are + # encountered. + # + # Like https://github.com/dbalatero/capybara-chromedriver-logger, but without + # Selenium 4 issues + # (https://github.com/dbalatero/capybara-chromedriver-logger/issues/34), and + # compatible with GeckoDriver. + log_inspector = Selenium::WebDriver::BiDi::LogInspector.new(driver.browser) + log_inspector.on_log do |log| + # Store the logs on a global (this might not be ideal, but Thread.current + # doesn't seem to work and this does). + $selenium_logs ||= [] # rubocop:disable Style/GlobalVars + $selenium_logs << log # rubocop:disable Style/GlobalVars + + # Print out any console output (regardless of log level) to the screen for + # better awareness and debugging. + warn "#{Rainbow("JavaScript [#{log.fetch("level")}]:").color(:yellow).bright} #{log.fetch("text")}\n #{Rainbow(log.inspect).color(:silver)}" + end # Set download path for Chrome < 77 driver.browser.download_path = ApiUmbrellaTestHelpers::Downloads::DOWNLOADS_ROOT @@ -76,6 +90,14 @@ def capybara_register_driver(driver_name, options = {}) end Capybara::Screenshot.register_driver(driver_name) do |driver, path| + # Chrome doesn't support Selenium's `full_page: true` option for + # `save_screenshot`, so manually resize the page to the content. + width = driver.execute_script("return Math.max(document.body.scrollWidth, document.body.offsetWidth, document.documentElement.clientWidth, document.documentElement.scrollWidth, document.documentElement.offsetWidth);") + 100 + width = 1024 if width < 1024 + height = driver.execute_script("return Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);") + 100 + height = 768 if height < 768 + driver.resize_window_to(driver.current_window_handle, width, height) + driver.browser.save_screenshot(path) end end @@ -104,17 +126,6 @@ def capybara_register_driver(driver_name, options = {}) Capybara::Screenshot.prune_strategy = :keep_last_run -Capybara::Chromedriver::Logger.raise_js_errors = true -Capybara::Chromedriver::Logger.filters = [ - # Ignore warnings about the self-signed localhost cert. - /127.0.0.1.*This site does not have a valid SSL certificate/, - - # Ignore expected ajax request failures. - /127.0.0.1.*the server responded with a status of 401/, - /127.0.0.1.*the server responded with a status of 403/, - /127.0.0.1.*the server responded with a status of 422/, -] - module Minitest module Capybara class Test < Minitest::Test @@ -124,6 +135,13 @@ class Test < Minitest::Test include ApiUmbrellaTestHelpers::CapybaraCustomBootstrapInputs include ApiUmbrellaTestHelpers::CapybaraSelectize + def setup + super + + # Reset logs + $selenium_logs = [] # rubocop:disable Style/GlobalVars + end + def teardown super @@ -134,9 +152,13 @@ def teardown # tests that may have changed the driver). ::Capybara.use_default_driver - # Inspect console logs/errors after each test and raise errors if - # JavaScript errors were encountered. - ::Capybara::Chromedriver::Logger::TestHooks.after_example! + # Inspect the gathered logs and fail if there are any error level logs. + error_logs = $selenium_logs.filter { |log| log.fetch("level") == "error" } # rubocop:disable Style/GlobalVars + # Fail tests if JavaScript errors were generated during the tests. + assert_equal([], error_logs) # rubocop:disable Minitest/AssertionInLifecycleHook + + # Reset logs + $selenium_logs = [] # rubocop:disable Style/GlobalVars end end end