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