From a20d43914d9844be0904f0895c20ccdb4b57115c Mon Sep 17 00:00:00 2001 From: Sanad Liaquat <sliaquat@gitlab.com> Date: Mon, 12 Oct 2020 21:07:26 +0000 Subject: [PATCH] Add e2e test for 2FA recovery via SSH Also moves ssh and command related code out of qa/git/repository.rb into its own files and add tests for them --- app/views/profiles/accounts/show.html.haml | 2 +- qa/qa.rb | 2 + qa/qa/git/repository.rb | 107 ++++++---------- qa/qa/page/profile/accounts/show.rb | 5 + .../1_manage/login/2fa_ssh_recovery_spec.rb | 65 ++++++++++ .../push_over_http_file_size_spec.rb | 2 +- .../repository/push_protected_branch_spec.rb | 2 +- .../clone_push_pull_personal_snippet_spec.rb | 2 +- .../clone_push_pull_project_snippet_spec.rb | 2 +- .../group/restrict_by_ip_address_spec.rb | 2 +- .../3_create/repository/file_locking_spec.rb | 2 +- .../3_create/repository/push_rules_spec.rb | 2 +- .../restrict_push_protected_branch_spec.rb | 2 +- qa/qa/support/otp.rb | 5 +- qa/qa/support/retrier.rb | 17 +-- qa/qa/support/run.rb | 43 +++++++ qa/qa/support/ssh.rb | 62 ++++++++++ qa/spec/git/repository_spec.rb | 22 ++-- qa/spec/support/run_spec.rb | 27 +++++ qa/spec/support/ssh_spec.rb | 114 ++++++++++++++++++ 20 files changed, 392 insertions(+), 95 deletions(-) create mode 100644 qa/qa/specs/features/browser_ui/1_manage/login/2fa_ssh_recovery_spec.rb create mode 100644 qa/qa/support/run.rb create mode 100644 qa/qa/support/ssh.rb create mode 100644 qa/spec/support/run_spec.rb create mode 100644 qa/spec/support/ssh_spec.rb diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 42f2f7c8b8bfe..15184c4bae94c 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -20,7 +20,7 @@ = link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-info' - else .gl-mb-3 - = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-success' + = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-success', data: { qa_selector: 'enable_2fa_button' } %hr - if display_providers_on_profile? diff --git a/qa/qa.rb b/qa/qa.rb index d0edd1936e596..8564b8e04a7fd 100644 --- a/qa/qa.rb +++ b/qa/qa.rb @@ -593,10 +593,12 @@ module Page autoload :Api, 'qa/support/api' autoload :Dates, 'qa/support/dates' autoload :Repeater, 'qa/support/repeater' + autoload :Run, 'qa/support/run' autoload :Retrier, 'qa/support/retrier' autoload :Waiter, 'qa/support/waiter' autoload :WaitForRequests, 'qa/support/wait_for_requests' autoload :OTP, 'qa/support/otp' + autoload :SSH, 'qa/support/ssh' end end diff --git a/qa/qa/git/repository.rb b/qa/qa/git/repository.rb index 512653e8c4a89..0f7e4fbbc9741 100644 --- a/qa/qa/git/repository.rb +++ b/qa/qa/git/repository.rb @@ -2,10 +2,8 @@ require 'cgi' require 'uri' -require 'open3' require 'fileutils' require 'tmpdir' -require 'tempfile' require 'securerandom' module QA @@ -13,8 +11,7 @@ module Git class Repository include Scenario::Actable include Support::Repeater - - RepositoryCommandError = Class.new(StandardError) + include Support::Run attr_writer :use_lfs, :gpg_key_id attr_accessor :env_vars @@ -64,7 +61,7 @@ def use_default_identity end def clone(opts = '') - clone_result = run("git clone #{opts} #{uri} ./", max_attempts: 3) + clone_result = run_git("git clone #{opts} #{uri} ./", max_attempts: 3) return clone_result.response unless clone_result.success? enable_lfs_result = enable_lfs if use_lfs? @@ -74,7 +71,7 @@ def clone(opts = '') def checkout(branch_name, new_branch: false) opts = new_branch ? '-b' : '' - run(%Q{git checkout #{opts} "#{branch_name}"}).to_s + run_git(%Q{git checkout #{opts} "#{branch_name}"}).to_s end def shallow_clone @@ -82,8 +79,8 @@ def shallow_clone end def configure_identity(name, email) - run(%Q{git config user.name "#{name}"}) - run(%Q{git config user.email #{email}}) + run_git(%Q{git config user.name "#{name}"}) + run_git(%Q{git config user.email #{email}}) end def commit_file(name, contents, message) @@ -97,33 +94,33 @@ def add_file(name, contents) ::File.write(name, contents) if use_lfs? - git_lfs_track_result = run(%Q{git lfs track #{name} --lockable}) + git_lfs_track_result = run_git(%Q{git lfs track #{name} --lockable}) return git_lfs_track_result.response unless git_lfs_track_result.success? end - git_add_result = run(%Q{git add #{name}}) + git_add_result = run_git(%Q{git add #{name}}) git_lfs_track_result.to_s + git_add_result.to_s end def add_tag(tag_name) - run("git tag #{tag_name}").to_s + run_git("git tag #{tag_name}").to_s end def delete_tag(tag_name) - run(%Q{git push origin --delete #{tag_name}}, max_attempts: 3).to_s + run_git(%Q{git push origin --delete #{tag_name}}, max_attempts: 3).to_s end def commit(message) - run(%Q{git commit -m "#{message}"}, max_attempts: 3).to_s + run_git(%Q{git commit -m "#{message}"}, max_attempts: 3).to_s end def commit_with_gpg(message) - run(%Q{git config user.signingkey #{@gpg_key_id} && git config gpg.program $(command -v gpg) && git commit -S -m "#{message}"}).to_s + run_git(%Q{git config user.signingkey #{@gpg_key_id} && git config gpg.program $(command -v gpg) && git commit -S -m "#{message}"}).to_s end def current_branch - run("git rev-parse --abbrev-ref HEAD").to_s + run_git("git rev-parse --abbrev-ref HEAD").to_s end def push_changes(branch = 'master', push_options: nil) @@ -131,53 +128,48 @@ def push_changes(branch = 'master', push_options: nil) cmd << push_options_hash_to_string(push_options) cmd << uri cmd << branch - run(cmd.compact.join(' '), max_attempts: 3).to_s + run_git(cmd.compact.join(' '), max_attempts: 3).to_s end def push_all_branches - run("git push --all").to_s + run_git("git push --all").to_s end def push_tags_and_branches(branches) - run("git push --tags origin #{branches.join(' ')}").to_s + run_git("git push --tags origin #{branches.join(' ')}").to_s end def merge(branch) - run("git merge #{branch}") + run_git("git merge #{branch}") end def init_repository - run("git init") + run_git("git init") end def pull(repository = nil, branch = nil) - run(['git', 'pull', repository, branch].compact.join(' ')) + run_git(['git', 'pull', repository, branch].compact.join(' ')) end def commits - run('git log --oneline').to_s.split("\n") + run_git('git log --oneline').to_s.split("\n") end def use_ssh_key(key) - @private_key_file = Tempfile.new("id_#{SecureRandom.hex(8)}") - File.binwrite(private_key_file, key.private_key) - File.chmod(0700, private_key_file) - - @known_hosts_file = Tempfile.new("known_hosts_#{SecureRandom.hex(8)}") - keyscan_params = ['-H'] - keyscan_params << "-p #{uri.port}" if uri.port - keyscan_params << uri.host - res = run("ssh-keyscan #{keyscan_params.join(' ')} >> #{known_hosts_file.path}") - return res.response unless res.success? + @ssh = Support::SSH.perform do |ssh| + ssh.key = key + ssh.uri = uri + ssh.setup(env: self.env_vars) + ssh + end - self.env_vars << %Q{GIT_SSH_COMMAND="ssh -i #{private_key_file.path} -o UserKnownHostsFile=#{known_hosts_file.path}"} + self.env_vars << %Q{GIT_SSH_COMMAND="ssh -i #{ssh.private_key_file.path} -o UserKnownHostsFile=#{ssh.known_hosts_file.path}"} end def delete_ssh_key return unless ssh_key_set? - private_key_file.close(true) - known_hosts_file.close(true) + ssh.delete end def push_with_git_protocol(version, file_name, file_content, commit_message = 'Initial commit') @@ -192,13 +184,13 @@ def push_with_git_protocol(version, file_name, file_content, commit_message = 'I def git_protocol=(value) raise ArgumentError, "Please specify the protocol you would like to use: 0, 1, or 2" unless %w[0 1 2].include?(value.to_s) - run("git config protocol.version #{value}") + run_git("git config protocol.version #{value}") end def fetch_supported_git_protocol # ls-remote is one command known to respond to Git protocol v2 so we use # it to get output including the version reported via Git tracing - result = run("git ls-remote #{uri}", env: "GIT_TRACE_PACKET=1", max_attempts: 3) + result = run_git("git ls-remote #{uri}", max_attempts: 3, env: [*self.env_vars, "GIT_TRACE_PACKET=1"]) result.response[/git< version (\d+)/, 1] || 'unknown' end @@ -219,19 +211,10 @@ def delete_netrc private - attr_reader :uri, :username, :password, :known_hosts_file, - :private_key_file, :use_lfs + attr_reader :uri, :username, :password, :ssh, :use_lfs alias_method :use_lfs?, :use_lfs - Result = Struct.new(:command, :exitstatus, :response) do - alias_method :to_s, :response - - def success? - exitstatus == 0 && !response.include?('Error encountered') - end - end - def add_credentials? return false if !username || !password return true unless ssh_key_set? @@ -240,7 +223,7 @@ def add_credentials? end def ssh_key_set? - !private_key_file.nil? + ssh && !ssh.private_key_file.nil? end def enable_lfs @@ -249,33 +232,11 @@ def enable_lfs touch_gitconfig_result = run("touch #{tmp_home_dir}/.gitconfig") return touch_gitconfig_result.response unless touch_gitconfig_result.success? - git_lfs_install_result = run('git lfs install') + git_lfs_install_result = run_git('git lfs install') touch_gitconfig_result.to_s + git_lfs_install_result.to_s end - def run(command_str, env: [], max_attempts: 1) - command = [env_vars, *env, command_str, '2>&1'].compact.join(' ') - result = nil - - repeat_until(max_attempts: max_attempts, raise_on_failure: false) do - Runtime::Logger.debug "Git: pwd=[#{Dir.pwd}], command=[#{command}]" - output, status = Open3.capture2e(command) - output.chomp! - Runtime::Logger.debug "Git: output=[#{output}], exitstatus=[#{status.exitstatus}]" - - result = Result.new(command, status.exitstatus, output) - - result.success? - end - - unless result.success? - raise RepositoryCommandError, "The command #{result.command} failed (#{result.exitstatus}) with the following output:\n#{result.response}" - end - - result - end - def default_credentials if ::QA::Runtime::User.ldap_user? [Runtime::User.ldap_username, Runtime::User.ldap_password] @@ -333,6 +294,10 @@ def netrc_content def netrc_already_contains_content? read_netrc_content.grep(/^#{Regexp.escape(netrc_content)}$/).any? end + + def run_git(command_str, env: self.env_vars, max_attempts: 1) + run(command_str, env: env, max_attempts: max_attempts, log_prefix: 'Git: ') + end end end end diff --git a/qa/qa/page/profile/accounts/show.rb b/qa/qa/page/profile/accounts/show.rb index cf7f7d80cfaaa..84a34d1da78a9 100644 --- a/qa/qa/page/profile/accounts/show.rb +++ b/qa/qa/page/profile/accounts/show.rb @@ -7,6 +7,7 @@ module Accounts class Show < Page::Base view 'app/views/profiles/accounts/show.html.haml' do element :delete_account_button, required: true + element :enable_2fa_button end view 'app/assets/javascripts/profile/account/components/delete_account_modal.vue' do @@ -14,6 +15,10 @@ class Show < Page::Base element :confirm_delete_account_button end + def click_enable_2fa_button + click_element(:enable_2fa_button) + end + def delete_account(password) click_element(:delete_account_button) diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/2fa_ssh_recovery_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/2fa_ssh_recovery_spec.rb new file mode 100644 index 0000000000000..e81ebd5fa9d6a --- /dev/null +++ b/qa/qa/specs/features/browser_ui/1_manage/login/2fa_ssh_recovery_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module QA + context 'Manage', :requires_admin, :skip_live_env do + describe '2FA' do + let!(:user) { Resource::User.fabricate_via_api! } + let!(:user_api_client) { Runtime::API::Client.new(:gitlab, user: user) } + let(:address) { QA::Runtime::Scenario.gitlab_address } + let(:uri) { URI.parse(address) } + let(:ssh_port) { uri.port == 80 ? '' : '2222' } + let!(:ssh_key) do + Resource::SSHKey.fabricate_via_api! do |resource| + resource.title = "key for ssh tests #{Time.now.to_f}" + resource.api_client = user_api_client + end + end + + before do + enable_2fa_for_user(user) + end + + it 'allows 2FA code recovery via ssh' do + recovery_code = Support::SSH.perform do |ssh| + ssh.key = ssh_key + ssh.uri = address.gsub(uri.port.to_s, ssh_port) + ssh.setup + output = ssh.reset_2fa_codes + output.scan(/([A-Za-z0-9]{16})\n/).flatten.first + end + + Flow::Login.sign_in(as: user, skip_page_validation: true) + Page::Main::TwoFactorAuth.perform do |two_fa_auth| + two_fa_auth.set_2fa_code(recovery_code) + two_fa_auth.click_verify_code_button + end + + expect(Page::Main::Menu.perform(&:signed_in?)).to be_truthy + + Page::Main::Menu.perform(&:sign_out) + Flow::Login.sign_in(as: user, skip_page_validation: true) + Page::Main::TwoFactorAuth.perform do |two_fa_auth| + two_fa_auth.set_2fa_code(recovery_code) + two_fa_auth.click_verify_code_button + end + + expect(page).to have_text('Invalid two-factor code') + end + + def enable_2fa_for_user(user) + Flow::Login.while_signed_in(as: user) do + Page::Main::Menu.perform(&:click_settings_link) + Page::Profile::Menu.perform(&:click_account) + Page::Profile::Accounts::Show.perform(&:click_enable_2fa_button) + + Page::Profile::TwoFactorAuth.perform do |two_fa_auth| + otp = QA::Support::OTP.new(two_fa_auth.otp_secret_content) + two_fa_auth.set_pin_code(otp.fresh_otp) + two_fa_auth.click_register_2fa_app_button + two_fa_auth.click_proceed_button + end + end + end + end + end +end diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb index 2ebab7d2a3056..222eb3771ade8 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_http_file_size_spec.rb @@ -41,7 +41,7 @@ module QA retry_on_fail do expect { push_new_file('oversize_file_2.bin', wait_for_push: false) } - .to raise_error(QA::Git::Repository::RepositoryCommandError, /remote: fatal: pack exceeds maximum allowed size/) + .to raise_error(QA::Support::Run::CommandError, /remote: fatal: pack exceeds maximum allowed size/) end end diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb index d20abd658c684..54d00209cc75f 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb @@ -35,7 +35,7 @@ module QA roles: Resource::ProtectedBranch::Roles::NO_ONE }) - expect { push_new_file(branch_name) }.to raise_error(QA::Git::Repository::RepositoryCommandError, /remote: GitLab: You are not allowed to push code to protected branches on this project\.([\s\S]+)\[remote rejected\] #{branch_name} -> #{branch_name} \(pre-receive hook declined\)/) + expect { push_new_file(branch_name) }.to raise_error(QA::Support::Run::CommandError, /remote: GitLab: You are not allowed to push code to protected branches on this project\.([\s\S]+)\[remote rejected\] #{branch_name} -> #{branch_name} \(pre-receive hook declined\)/) end end diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb index a85efe5829ba9..a3f6d5217663c 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb @@ -91,7 +91,7 @@ module QA repository.init_repository expect { repository.pull(repository_uri_ssh, branch_name) } - .to raise_error(QA::Git::Repository::RepositoryCommandError, /fatal: Could not read from remote repository\./) + .to raise_error(QA::Support::Run::CommandError, /fatal: Could not read from remote repository\./) end end diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb index 9789f7a17160d..be56b870490d9 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb @@ -90,7 +90,7 @@ module QA repository.init_repository expect { repository.pull(repository_uri_ssh, branch_name) } - .to raise_error(QA::Git::Repository::RepositoryCommandError, /fatal: Could not read from remote repository\./) + .to raise_error(QA::Support::Run::CommandError, /fatal: Could not read from remote repository\./) end end diff --git a/qa/qa/specs/features/ee/browser_ui/1_manage/group/restrict_by_ip_address_spec.rb b/qa/qa/specs/features/ee/browser_ui/1_manage/group/restrict_by_ip_address_spec.rb index c9717f934e13c..40b78bc314d2a 100644 --- a/qa/qa/specs/features/ee/browser_ui/1_manage/group/restrict_by_ip_address_spec.rb +++ b/qa/qa/specs/features/ee/browser_ui/1_manage/group/restrict_by_ip_address_spec.rb @@ -88,7 +88,7 @@ module QA end it 'denies access', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/860' do - expect { push_a_project_with_ssh_key(key) }.to raise_error(QA::Git::Repository::RepositoryCommandError, /fatal: Could not read from remote repository/) + expect { push_a_project_with_ssh_key(key) }.to raise_error(QA::Support::Run::CommandError, /fatal: Could not read from remote repository/) end end end diff --git a/qa/qa/specs/features/ee/browser_ui/3_create/repository/file_locking_spec.rb b/qa/qa/specs/features/ee/browser_ui/3_create/repository/file_locking_spec.rb index 3865b7d76a2c4..b21368f7969bb 100644 --- a/qa/qa/specs/features/ee/browser_ui/3_create/repository/file_locking_spec.rb +++ b/qa/qa/specs/features/ee/browser_ui/3_create/repository/file_locking_spec.rb @@ -144,7 +144,7 @@ def push(branch: 'master', file: 'file', as_user:) def expect_error_on_push(for_file: 'file', as_user:) expect { push branch: 'master', file: for_file, as_user: as_user }.to raise_error( - QA::Git::Repository::RepositoryCommandError) + QA::Support::Run::CommandError) end def expect_no_error_on_push(for_file: 'file', as_user:) diff --git a/qa/qa/specs/features/ee/browser_ui/3_create/repository/push_rules_spec.rb b/qa/qa/specs/features/ee/browser_ui/3_create/repository/push_rules_spec.rb index a83d83a1f1515..3b7d53d545040 100644 --- a/qa/qa/specs/features/ee/browser_ui/3_create/repository/push_rules_spec.rb +++ b/qa/qa/specs/features/ee/browser_ui/3_create/repository/push_rules_spec.rb @@ -191,7 +191,7 @@ def expect_no_error_on_push(commit_message: 'allowed commit', branch: 'master', def expect_error_on_push(commit_message: 'allowed commit', branch: 'master', file:, user: @creator, tag: nil, gpg: nil, error: nil) expect do push commit_message: commit_message, branch: branch, file: file, user: user, tag: tag, gpg: gpg - end.to raise_error(QA::Git::Repository::RepositoryCommandError, /#{error}/) + end.to raise_error(QA::Support::Run::CommandError, /#{error}/) end def prepare diff --git a/qa/qa/specs/features/ee/browser_ui/3_create/repository/restrict_push_protected_branch_spec.rb b/qa/qa/specs/features/ee/browser_ui/3_create/repository/restrict_push_protected_branch_spec.rb index 0d3d9fff5a15b..2377ebcf8fe85 100644 --- a/qa/qa/specs/features/ee/browser_ui/3_create/repository/restrict_push_protected_branch_spec.rb +++ b/qa/qa/specs/features/ee/browser_ui/3_create/repository/restrict_push_protected_branch_spec.rb @@ -11,7 +11,7 @@ module QA shared_examples 'only user with access pushes and merges' do it 'unselected maintainer user fails to push' do expect { push_new_file(branch_name, as_user: user_maintainer) }.to raise_error( - QA::Git::Repository::RepositoryCommandError, + QA::Support::Run::CommandError, /remote: GitLab: You are not allowed to push code to protected branches on this project\.([\s\S]+)\[remote rejected\] #{branch_name} -> #{branch_name} \(pre-receive hook declined\)/) end diff --git a/qa/qa/support/otp.rb b/qa/qa/support/otp.rb index 0d7c394cf6965..0a0dc64a726ec 100644 --- a/qa/qa/support/otp.rb +++ b/qa/qa/support/otp.rb @@ -13,11 +13,14 @@ def fresh_otp # Fetches a fresh OTP and returns it only after rotp provides the same OTP twice # An OTP is valid for 30 seconds so 70 attempts with 0.5 interval would ensure we complete 1 cycle - Support::Retrier.retry_until(max_attempts: 70, sleep_interval: 0.5) do + + QA::Runtime::Logger.debug("Fetching a fresh OTP...") + Support::Retrier.retry_until(max_attempts: 70, sleep_interval: 0.5, log: false) do otps << @rotp.now otps.size >= 3 && otps[-1] == otps[-2] && otps[-1] != otps[-3] end + QA::Runtime::Logger.debug("Fetched OTP: #{otps.last}") otps.last end end diff --git a/qa/qa/support/retrier.rb b/qa/qa/support/retrier.rb index f28534e7c113d..25dbb42cf6f03 100644 --- a/qa/qa/support/retrier.rb +++ b/qa/qa/support/retrier.rb @@ -34,15 +34,17 @@ def retry_on_exception(max_attempts: 3, reload_page: nil, sleep_interval: 0.5) result end - def retry_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: true, retry_on_exception: false) + def retry_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: true, retry_on_exception: false, log: true) # For backwards-compatibility max_attempts = 3 if max_attempts.nil? && max_duration.nil? - start_msg ||= ["with retry_until:"] - start_msg << "max_attempts: #{max_attempts};" if max_attempts - start_msg << "max_duration: #{max_duration};" if max_duration - start_msg << "reload_page: #{reload_page}; sleep_interval: #{sleep_interval}; raise_on_failure: #{raise_on_failure}; retry_on_exception: #{retry_on_exception}" - QA::Runtime::Logger.debug(start_msg.join(' ')) + if log + start_msg ||= ["with retry_until:"] + start_msg << "max_attempts: #{max_attempts};" if max_attempts + start_msg << "max_duration: #{max_duration};" if max_duration + start_msg << "reload_page: #{reload_page}; sleep_interval: #{sleep_interval}; raise_on_failure: #{raise_on_failure}; retry_on_exception: #{retry_on_exception}" + QA::Runtime::Logger.debug(start_msg.join(' ')) + end result = nil repeat_until( @@ -51,7 +53,8 @@ def retry_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_in reload_page: reload_page, sleep_interval: sleep_interval, raise_on_failure: raise_on_failure, - retry_on_exception: retry_on_exception + retry_on_exception: retry_on_exception, + log: log ) do result = yield end diff --git a/qa/qa/support/run.rb b/qa/qa/support/run.rb new file mode 100644 index 0000000000000..a91e7dfd2cbab --- /dev/null +++ b/qa/qa/support/run.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'open3' + +module QA + module Support + module Run + include QA::Support::Repeater + + CommandError = Class.new(StandardError) + + Result = Struct.new(:command, :exitstatus, :response) do + alias_method :to_s, :response + + def success? + exitstatus == 0 && !response.include?('Error encountered') + end + end + + def run(command_str, env: [], max_attempts: 1, log_prefix: '') + command = [*env, command_str, '2>&1'].compact.join(' ') + result = nil + + repeat_until(max_attempts: max_attempts, raise_on_failure: false) do + Runtime::Logger.debug "#{log_prefix}pwd=[#{Dir.pwd}], command=[#{command}]" + output, status = Open3.capture2e(command) + output.chomp! + Runtime::Logger.debug "#{log_prefix}output=[#{output}], exitstatus=[#{status.exitstatus}]" + + result = Result.new(command, status.exitstatus, output) + + result.success? + end + + unless result.success? + raise CommandError, "The command #{result.command} failed (#{result.exitstatus}) with the following output:\n#{result.response}" + end + + result + end + end + end +end diff --git a/qa/qa/support/ssh.rb b/qa/qa/support/ssh.rb new file mode 100644 index 0000000000000..a5e8e96cb6cc8 --- /dev/null +++ b/qa/qa/support/ssh.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'tempfile' +require 'etc' + +module QA + module Support + class SSH + include Scenario::Actable + include Support::Run + + attr_accessor :known_hosts_file, :private_key_file, :key + attr_reader :uri + + def initialize + @private_key_file = Tempfile.new("id_#{SecureRandom.hex(8)}") + @known_hosts_file = Tempfile.new("known_hosts_#{SecureRandom.hex(8)}") + end + + def uri=(address) + @uri = URI(address) + end + + def setup(env: nil) + File.binwrite(private_key_file, key.private_key) + File.chmod(0700, private_key_file) + + keyscan_params = ['-H'] + keyscan_params << "-p #{uri_port}" if uri_port + keyscan_params << uri.host + + res = run("ssh-keyscan #{keyscan_params.join(' ')} >> #{known_hosts_file.path}", env: env, log_prefix: 'SSH: ') + return res.response unless res.success? + + true + end + + def delete + private_key_file.close(true) + known_hosts_file.close(true) + end + + def reset_2fa_codes + ssh_params = [uri.host] + ssh_params << "-p #{uri_port}" if uri_port + ssh_params << "2fa_recovery_codes" + + run("echo yes | ssh -i #{private_key_file.path} -o UserKnownHostsFile=#{known_hosts_file.path} #{git_user}@#{ssh_params.join(' ')}", log_prefix: 'SSH: ').to_s + end + + private + + def uri_port + uri.port && (uri.port != 80) ? uri.port : nil + end + + def git_user + QA::Runtime::Env.running_in_ci? || [443, 80].include?(uri.port) ? 'git' : Etc.getlogin + end + end + end +end diff --git a/qa/spec/git/repository_spec.rb b/qa/spec/git/repository_spec.rb index 2ac7a99d82f66..02bb7783c288b 100644 --- a/qa/spec/git/repository_spec.rb +++ b/qa/spec/git/repository_spec.rb @@ -6,7 +6,16 @@ shared_context 'unresolvable git directory' do let(:repo_uri) { 'http://foo/bar.git' } let(:repo_uri_with_credentials) { 'http://root@foo/bar.git' } - let(:repository) { described_class.new.tap { |r| r.uri = repo_uri } } + let(:env_vars) { [%q{HOME="temp"}] } + let(:extra_env_vars) { [] } + let(:run_params) { { env: env_vars + extra_env_vars, log_prefix: "Git: " } } + let(:repository) do + described_class.new.tap do |r| + r.uri = repo_uri + r.env_vars = env_vars + end + end + let(:tmp_git_dir) { Dir.mktmpdir } let(:tmp_netrc_dir) { Dir.mktmpdir } @@ -28,14 +37,13 @@ end shared_examples 'command with retries' do - let(:extra_args) { {} } let(:result_output) { +'Command successful' } let(:result) { described_class::Result.new(any_args, 0, result_output) } let(:command_return) { result_output } context 'when command is successful' do it 'returns the #run command Result output' do - expect(repository).to receive(:run).with(command, extra_args.merge(max_attempts: 3)).and_return(result) + expect(repository).to receive(:run).with(command, run_params.merge(max_attempts: 3)).and_return(result) expect(call_method).to eq(command_return) end @@ -52,10 +60,10 @@ end context 'and retried command is not successful after 3 attempts' do - it 'raises a RepositoryCommandError exception' do + it 'raises a CommandError exception' do expect(Open3).to receive(:capture2e).and_return([+'FAILURE', double(exitstatus: 42)]).exactly(3).times - expect { call_method }.to raise_error(described_class::RepositoryCommandError, /The command .* failed \(42\) with the following output:\nFAILURE/) + expect { call_method }.to raise_error(QA::Support::Run::CommandError, /The command .* failed \(42\) with the following output:\nFAILURE/) end end end @@ -182,7 +190,7 @@ describe '#git_protocol=' do [0, 1, 2].each do |version| it "configures git to use protocol version #{version}" do - expect(repository).to receive(:run).with("git config protocol.version #{version}") + expect(repository).to receive(:run).with("git config protocol.version #{version}", run_params.merge(max_attempts: 1)) repository.git_protocol = version end @@ -200,7 +208,7 @@ let(:command) { "git ls-remote #{repo_uri_with_credentials}" } let(:result_output) { +'packet: git< version 2' } let(:command_return) { '2' } - let(:extra_args) { { env: "GIT_TRACE_PACKET=1" } } + let(:extra_env_vars) { ["GIT_TRACE_PACKET=1"] } end it "reports the detected version" do diff --git a/qa/spec/support/run_spec.rb b/qa/spec/support/run_spec.rb new file mode 100644 index 0000000000000..62eed71012eb1 --- /dev/null +++ b/qa/spec/support/run_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe QA::Support::Run do + let(:class_instance) { (Class.new { include QA::Support::Run }).new } + let(:response) { 'successful response' } + let(:command) { 'some command' } + let(:expected_result) { described_class::Result.new("#{command} 2>&1", 0, response) } + + it 'runs successfully' do + expect(Open3).to receive(:capture2e).and_return([+response, double(exitstatus: 0)]) + + expect(class_instance.run(command)).to eq(expected_result) + end + + it 'retries twice and succeeds the third time' do + allow(Open3).to receive(:capture2e).and_return([+'', double(exitstatus: 1)]).twice + allow(Open3).to receive(:capture2e).and_return([+response, double(exitstatus: 0)]) + + expect(class_instance.run(command)).to eq(expected_result) + end + + it 'raises an exception on 3rd failure' do + allow(Open3).to receive(:capture2e).and_return([+'FAILURE', double(exitstatus: 1)]).thrice + + expect { class_instance.run(command) }.to raise_error(QA::Support::Run::CommandError, /The command .* failed \(1\) with the following output:\nFAILURE/) + end +end diff --git a/qa/spec/support/ssh_spec.rb b/qa/spec/support/ssh_spec.rb new file mode 100644 index 0000000000000..f4d382f8adcc0 --- /dev/null +++ b/qa/spec/support/ssh_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +RSpec.describe QA::Support::SSH do + let(:key) { Struct.new(:private_key).new('private_key') } + let(:known_hosts_file) { Tempfile.new('known_hosts_file') } + let(:private_key_file) { Tempfile.new('private_key_file') } + let(:result) { QA::Support::Run::Result.new('', 0, '') } + + let(:ssh) do + described_class.new.tap do |ssh| + ssh.uri = uri + ssh.key = key + ssh.private_key_file = private_key_file + ssh.known_hosts_file = known_hosts_file + end + end + + shared_examples 'providing correct ports' do + context 'when no port specified in uri' do + let(:uri) { 'http://foo.com' } + + it 'does not provide port in ssh command' do + expect(ssh).to receive(:run).with(expected_ssh_command_no_port, any_args).and_return(result) + + call_method + end + end + + context 'when port 80 specified in uri' do + let(:uri) { 'http://foo.com:80' } + + it 'does not provide port in ssh command' do + expect(ssh).to receive(:run).with(expected_ssh_command_no_port, any_args).and_return(result) + + call_method + end + end + + context 'when other port is specified in uri' do + let(:port) { 1234 } + let(:uri) { "http://foo.com:#{port}" } + + it "provides other port in ssh command" do + expect(ssh).to receive(:run).with(expected_ssh_command_port, any_args).and_return(result) + + call_method + end + end + end + + describe '#setup' do + let(:expected_ssh_command_no_port) { "ssh-keyscan -H foo.com >> #{known_hosts_file.path}" } + let(:expected_ssh_command_port) { "ssh-keyscan -H -p #{port} foo.com >> #{known_hosts_file.path}" } + let(:call_method) { ssh.setup } + + before do + allow(File).to receive(:binwrite).with(private_key_file, key.private_key) + allow(File).to receive(:chmod).with(0700, private_key_file) + end + + it_behaves_like 'providing correct ports' + end + + describe '#reset_2fa_codes' do + let(:expected_ssh_command_no_port) { "echo yes | ssh -i #{private_key_file.path} -o UserKnownHostsFile=#{known_hosts_file.path} git@foo.com 2fa_recovery_codes" } + let(:expected_ssh_command_port) { "echo yes | ssh -i #{private_key_file.path} -o UserKnownHostsFile=#{known_hosts_file.path} git@foo.com -p #{port} 2fa_recovery_codes" } + let(:call_method) { ssh.reset_2fa_codes } + + before do + allow(ssh).to receive(:git_user).and_return('git') + end + + it_behaves_like 'providing correct ports' + end + + describe '#git_user' do + context 'when running on CI' do + let(:uri) { 'http://gitlab.com' } + + before do + allow(QA::Runtime::Env).to receive(:running_in_ci?).and_return(true) + end + + it 'returns git user' do + expect(ssh.send(:git_user)).to eq('git') + end + end + + context 'when running against environment on a port other than 80 or 443' do + let(:uri) { 'http://localhost:3000' } + + before do + allow(Etc).to receive(:getlogin).and_return('dummy_username') + allow(QA::Runtime::Env).to receive(:running_in_ci?).and_return(false) + end + + it 'returns the local user' do + expect(ssh.send(:git_user)).to eq('dummy_username') + end + end + + context 'when running against environment on port 80 and not on CI (docker)' do + let(:uri) { 'http://localhost' } + + before do + allow(QA::Runtime::Env).to receive(:running_in_ci?).and_return(false) + end + + it 'returns git user' do + expect(ssh.send(:git_user)).to eq('git') + end + end + end +end -- GitLab