Commit a20d4391 authored by Sanad Liaquat's avatar Sanad Liaquat Committed by Mark Lapierre

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
parent c70d2901
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
= link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-info' = link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-info'
- else - else
.gl-mb-3 .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 %hr
- if display_providers_on_profile? - if display_providers_on_profile?
......
...@@ -593,10 +593,12 @@ module QA ...@@ -593,10 +593,12 @@ module QA
autoload :Api, 'qa/support/api' autoload :Api, 'qa/support/api'
autoload :Dates, 'qa/support/dates' autoload :Dates, 'qa/support/dates'
autoload :Repeater, 'qa/support/repeater' autoload :Repeater, 'qa/support/repeater'
autoload :Run, 'qa/support/run'
autoload :Retrier, 'qa/support/retrier' autoload :Retrier, 'qa/support/retrier'
autoload :Waiter, 'qa/support/waiter' autoload :Waiter, 'qa/support/waiter'
autoload :WaitForRequests, 'qa/support/wait_for_requests' autoload :WaitForRequests, 'qa/support/wait_for_requests'
autoload :OTP, 'qa/support/otp' autoload :OTP, 'qa/support/otp'
autoload :SSH, 'qa/support/ssh'
end end
end end
......
...@@ -2,10 +2,8 @@ ...@@ -2,10 +2,8 @@
require 'cgi' require 'cgi'
require 'uri' require 'uri'
require 'open3'
require 'fileutils' require 'fileutils'
require 'tmpdir' require 'tmpdir'
require 'tempfile'
require 'securerandom' require 'securerandom'
module QA module QA
...@@ -13,8 +11,7 @@ module QA ...@@ -13,8 +11,7 @@ module QA
class Repository class Repository
include Scenario::Actable include Scenario::Actable
include Support::Repeater include Support::Repeater
include Support::Run
RepositoryCommandError = Class.new(StandardError)
attr_writer :use_lfs, :gpg_key_id attr_writer :use_lfs, :gpg_key_id
attr_accessor :env_vars attr_accessor :env_vars
...@@ -64,7 +61,7 @@ module QA ...@@ -64,7 +61,7 @@ module QA
end end
def clone(opts = '') 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? return clone_result.response unless clone_result.success?
enable_lfs_result = enable_lfs if use_lfs? enable_lfs_result = enable_lfs if use_lfs?
...@@ -74,7 +71,7 @@ module QA ...@@ -74,7 +71,7 @@ module QA
def checkout(branch_name, new_branch: false) def checkout(branch_name, new_branch: false)
opts = new_branch ? '-b' : '' opts = new_branch ? '-b' : ''
run(%Q{git checkout #{opts} "#{branch_name}"}).to_s run_git(%Q{git checkout #{opts} "#{branch_name}"}).to_s
end end
def shallow_clone def shallow_clone
...@@ -82,8 +79,8 @@ module QA ...@@ -82,8 +79,8 @@ module QA
end end
def configure_identity(name, email) def configure_identity(name, email)
run(%Q{git config user.name "#{name}"}) run_git(%Q{git config user.name "#{name}"})
run(%Q{git config user.email #{email}}) run_git(%Q{git config user.email #{email}})
end end
def commit_file(name, contents, message) def commit_file(name, contents, message)
...@@ -97,33 +94,33 @@ module QA ...@@ -97,33 +94,33 @@ module QA
::File.write(name, contents) ::File.write(name, contents)
if use_lfs? 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? return git_lfs_track_result.response unless git_lfs_track_result.success?
end 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 git_lfs_track_result.to_s + git_add_result.to_s
end end
def add_tag(tag_name) def add_tag(tag_name)
run("git tag #{tag_name}").to_s run_git("git tag #{tag_name}").to_s
end end
def delete_tag(tag_name) 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 end
def commit(message) 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 end
def commit_with_gpg(message) 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 end
def current_branch def current_branch
run("git rev-parse --abbrev-ref HEAD").to_s run_git("git rev-parse --abbrev-ref HEAD").to_s
end end
def push_changes(branch = 'master', push_options: nil) def push_changes(branch = 'master', push_options: nil)
...@@ -131,53 +128,48 @@ module QA ...@@ -131,53 +128,48 @@ module QA
cmd << push_options_hash_to_string(push_options) cmd << push_options_hash_to_string(push_options)
cmd << uri cmd << uri
cmd << branch cmd << branch
run(cmd.compact.join(' '), max_attempts: 3).to_s run_git(cmd.compact.join(' '), max_attempts: 3).to_s
end end
def push_all_branches def push_all_branches
run("git push --all").to_s run_git("git push --all").to_s
end end
def push_tags_and_branches(branches) 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 end
def merge(branch) def merge(branch)
run("git merge #{branch}") run_git("git merge #{branch}")
end end
def init_repository def init_repository
run("git init") run_git("git init")
end end
def pull(repository = nil, branch = nil) def pull(repository = nil, branch = nil)
run(['git', 'pull', repository, branch].compact.join(' ')) run_git(['git', 'pull', repository, branch].compact.join(' '))
end end
def commits def commits
run('git log --oneline').to_s.split("\n") run_git('git log --oneline').to_s.split("\n")
end end
def use_ssh_key(key) def use_ssh_key(key)
@private_key_file = Tempfile.new("id_#{SecureRandom.hex(8)}") @ssh = Support::SSH.perform do |ssh|
File.binwrite(private_key_file, key.private_key) ssh.key = key
File.chmod(0700, private_key_file) ssh.uri = uri
ssh.setup(env: self.env_vars)
@known_hosts_file = Tempfile.new("known_hosts_#{SecureRandom.hex(8)}") ssh
keyscan_params = ['-H'] end
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?
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 end
def delete_ssh_key def delete_ssh_key
return unless ssh_key_set? return unless ssh_key_set?
private_key_file.close(true) ssh.delete
known_hosts_file.close(true)
end end
def push_with_git_protocol(version, file_name, file_content, commit_message = 'Initial commit') def push_with_git_protocol(version, file_name, file_content, commit_message = 'Initial commit')
...@@ -192,13 +184,13 @@ module QA ...@@ -192,13 +184,13 @@ module QA
def git_protocol=(value) 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) 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 end
def fetch_supported_git_protocol def fetch_supported_git_protocol
# ls-remote is one command known to respond to Git protocol v2 so we use # 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 # 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' result.response[/git< version (\d+)/, 1] || 'unknown'
end end
...@@ -219,19 +211,10 @@ module QA ...@@ -219,19 +211,10 @@ module QA
private private
attr_reader :uri, :username, :password, :known_hosts_file, attr_reader :uri, :username, :password, :ssh, :use_lfs
:private_key_file, :use_lfs
alias_method :use_lfs?, :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? def add_credentials?
return false if !username || !password return false if !username || !password
return true unless ssh_key_set? return true unless ssh_key_set?
...@@ -240,7 +223,7 @@ module QA ...@@ -240,7 +223,7 @@ module QA
end end
def ssh_key_set? def ssh_key_set?
!private_key_file.nil? ssh && !ssh.private_key_file.nil?
end end
def enable_lfs def enable_lfs
...@@ -249,33 +232,11 @@ module QA ...@@ -249,33 +232,11 @@ module QA
touch_gitconfig_result = run("touch #{tmp_home_dir}/.gitconfig") touch_gitconfig_result = run("touch #{tmp_home_dir}/.gitconfig")
return touch_gitconfig_result.response unless touch_gitconfig_result.success? 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 touch_gitconfig_result.to_s + git_lfs_install_result.to_s
end 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 def default_credentials
if ::QA::Runtime::User.ldap_user? if ::QA::Runtime::User.ldap_user?
[Runtime::User.ldap_username, Runtime::User.ldap_password] [Runtime::User.ldap_username, Runtime::User.ldap_password]
...@@ -333,6 +294,10 @@ module QA ...@@ -333,6 +294,10 @@ module QA
def netrc_already_contains_content? def netrc_already_contains_content?
read_netrc_content.grep(/^#{Regexp.escape(netrc_content)}$/).any? read_netrc_content.grep(/^#{Regexp.escape(netrc_content)}$/).any?
end 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 end
end end
...@@ -7,6 +7,7 @@ module QA ...@@ -7,6 +7,7 @@ module QA
class Show < Page::Base class Show < Page::Base
view 'app/views/profiles/accounts/show.html.haml' do view 'app/views/profiles/accounts/show.html.haml' do
element :delete_account_button, required: true element :delete_account_button, required: true
element :enable_2fa_button
end end
view 'app/assets/javascripts/profile/account/components/delete_account_modal.vue' do view 'app/assets/javascripts/profile/account/components/delete_account_modal.vue' do
...@@ -14,6 +15,10 @@ module QA ...@@ -14,6 +15,10 @@ module QA
element :confirm_delete_account_button element :confirm_delete_account_button
end end
def click_enable_2fa_button
click_element(:enable_2fa_button)
end
def delete_account(password) def delete_account(password)
click_element(:delete_account_button) click_element(:delete_account_button)
......
# 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
...@@ -41,7 +41,7 @@ module QA ...@@ -41,7 +41,7 @@ module QA
retry_on_fail do retry_on_fail do
expect { push_new_file('oversize_file_2.bin', wait_for_push: false) } 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
end end
......
...@@ -35,7 +35,7 @@ module QA ...@@ -35,7 +35,7 @@ module QA
roles: Resource::ProtectedBranch::Roles::NO_ONE 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
end end
......
...@@ -91,7 +91,7 @@ module QA ...@@ -91,7 +91,7 @@ module QA
repository.init_repository repository.init_repository
expect { repository.pull(repository_uri_ssh, branch_name) } 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
end end
......
...@@ -90,7 +90,7 @@ module QA ...@@ -90,7 +90,7 @@ module QA
repository.init_repository repository.init_repository
expect { repository.pull(repository_uri_ssh, branch_name) } 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
end end
......
...@@ -88,7 +88,7 @@ module QA ...@@ -88,7 +88,7 @@ module QA
end end
it 'denies access', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/860' do 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 end
end end
......
...@@ -144,7 +144,7 @@ module QA ...@@ -144,7 +144,7 @@ module QA
def expect_error_on_push(for_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( expect { push branch: 'master', file: for_file, as_user: as_user }.to raise_error(
QA::Git::Repository::RepositoryCommandError) QA::Support::Run::CommandError)
end end
def expect_no_error_on_push(for_file: 'file', as_user:) def expect_no_error_on_push(for_file: 'file', as_user:)
......
...@@ -191,7 +191,7 @@ module QA ...@@ -191,7 +191,7 @@ module QA
def expect_error_on_push(commit_message: 'allowed commit', branch: 'master', file:, user: @creator, tag: nil, gpg: nil, error: nil) def expect_error_on_push(commit_message: 'allowed commit', branch: 'master', file:, user: @creator, tag: nil, gpg: nil, error: nil)
expect do expect do
push commit_message: commit_message, branch: branch, file: file, user: user, tag: tag, gpg: gpg 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 end
def prepare def prepare
......
...@@ -11,7 +11,7 @@ module QA ...@@ -11,7 +11,7 @@ module QA
shared_examples 'only user with access pushes and merges' do shared_examples 'only user with access pushes and merges' do
it 'unselected maintainer user fails to push' do it 'unselected maintainer user fails to push' do
expect { push_new_file(branch_name, as_user: user_maintainer) }.to raise_error( 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\)/) /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
......
...@@ -13,11 +13,14 @@ module QA ...@@ -13,11 +13,14 @@ module QA
# Fetches a fresh OTP and returns it only after rotp provides the same OTP twice # 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 # 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 << @rotp.now
otps.size >= 3 && otps[-1] == otps[-2] && otps[-1] != otps[-3] otps.size >= 3 && otps[-1] == otps[-2] && otps[-1] != otps[-3]
end end
QA::Runtime::Logger.debug("Fetched OTP: #{otps.last}")
otps.last otps.last
end end
end end
......
...@@ -34,15 +34,17 @@ module QA ...@@ -34,15 +34,17 @@ module QA
result result
end 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 # For backwards-compatibility
max_attempts = 3 if max_attempts.nil? && max_duration.nil? max_attempts = 3 if max_attempts.nil? && max_duration.nil?
start_msg ||= ["with retry_until:"] if log
start_msg << "max_attempts: #{max_attempts};" if max_attempts start_msg ||= ["with retry_until:"]
start_msg << "max_duration: #{max_duration};" if max_duration start_msg << "max_attempts: #{max_attempts};" if max_attempts
start_msg << "reload_page: #{reload_page}; sleep_interval: #{sleep_interval}; raise_on_failure: #{raise_on_failure}; retry_on_exception: #{retry_on_exception}" start_msg << "max_duration: #{max_duration};" if max_duration
QA::Runtime::Logger.debug(start_msg.join(' ')) 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 result = nil
repeat_until( repeat_until(
...@@ -51,7 +53,8 @@ module QA ...@@ -51,7 +53,8 @@ module QA
reload_page: reload_page, reload_page: reload_page,
sleep_interval: sleep_interval, sleep_interval: sleep_interval,
raise_on_failure: raise_on_failure, raise_on_failure: raise_on_failure,
retry_on_exception: retry_on_exception retry_on_exception: retry_on_exception,
log: log
) do ) do
result = yield result = yield
end end
......
# 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
# 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
...@@ -6,7 +6,16 @@ RSpec.describe QA::Git::Repository do ...@@ -6,7 +6,16 @@ RSpec.describe QA::Git::Repository do
shared_context 'unresolvable git directory' do shared_context 'unresolvable git directory' do
let(:repo_uri) { 'http://foo/bar.git' } let(:repo_uri) { 'http://foo/bar.git' }
let(:repo_uri_with_credentials) { 'http://root@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_git_dir) { Dir.mktmpdir }
let(:tmp_netrc_dir) { Dir.mktmpdir } let(:tmp_netrc_dir) { Dir.mktmpdir }
...@@ -28,14 +37,13 @@ RSpec.describe QA::Git::Repository do ...@@ -28,14 +37,13 @@ RSpec.describe QA::Git::Repository do
end end
shared_examples 'command with retries' do shared_examples 'command with retries' do
let(:extra_args) { {} }
let(:result_output) { +'Command successful' } let(:result_output) { +'Command successful' }
let(:result) { described_class::Result.new(any_args, 0, result_output) } let(:result) { described_class::Result.new(any_args, 0, result_output) }
let(:command_return) { result_output } let(:command_return) { result_output }
context 'when command is successful' do context 'when command is successful' do
it 'returns the #run command Result output' 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) expect(call_method).to eq(command_return)
end end
...@@ -52,10 +60,10 @@ RSpec.describe QA::Git::Repository do ...@@ -52,10 +60,10 @@ RSpec.describe QA::Git::Repository do
end end
context 'and retried command is not successful after 3 attempts' do 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(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 end
end end
...@@ -182,7 +190,7 @@ RSpec.describe QA::Git::Repository do ...@@ -182,7 +190,7 @@ RSpec.describe QA::Git::Repository do
describe '#git_protocol=' do describe '#git_protocol=' do
[0, 1, 2].each do |version| [0, 1, 2].each do |version|
it "configures git to use protocol version #{version}" do 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 repository.git_protocol = version
end end
...@@ -200,7 +208,7 @@ RSpec.describe QA::Git::Repository do ...@@ -200,7 +208,7 @@ RSpec.describe QA::Git::Repository do
let(:command) { "git ls-remote #{repo_uri_with_credentials}" } let(:command) { "git ls-remote #{repo_uri_with_credentials}" }
let(:result_output) { +'packet: git< version 2' } let(:result_output) { +'packet: git< version 2' }
let(:command_return) { '2' } let(:command_return) { '2' }
let(:extra_args) { { env: "GIT_TRACE_PACKET=1" } } let(:extra_env_vars) { ["GIT_TRACE_PACKET=1"] }
end end
it "reports the detected version" do it "reports the detected version" do
......
# 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
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment