# -*- encoding: utf-8 -*-
#
# Author:: Fletcher Nichol (<fnichol@nichol.ca>)
#
# Copyright (C) 2015, Fletcher Nichol
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require_relative "../../spec_helper"

require "kitchen/transport/ssh"
require "net/ssh/test"

describe Kitchen::Transport::Ssh do
  let(:logged_output) { StringIO.new }
  let(:logger)        { Logger.new(logged_output) }
  let(:config)        { Hash.new }
  let(:state)         { Hash.new }

  let(:instance) do
    stub(name: "coolbeans", logger: logger, to_str: "instance")
  end

  let(:transport) do
    Net::SSH::Test::Extensions::IO.with_test_extension { Kitchen::Transport::Ssh.new(config).finalize_config!(instance) }
  end

  it "provisioner api_version is 1" do
    transport.diagnose_plugin[:api_version].must_equal 1
  end

  it "plugin_version is set to Kitchen::VERSION" do
    transport.diagnose_plugin[:version].must_equal Kitchen::VERSION
  end

  describe "default_config" do
    it "sets :port to 22 by default" do
      transport[:port].must_equal 22
    end

    it "sets :username to root by default" do
      transport[:username].must_equal "root"
    end

    it "sets :compression to true by default" do
      transport[:compression].must_equal false
    end

    it "sets :compression to false if set to none" do
      config[:compression] = "none"

      transport[:compression].must_equal false
    end

    it "sets :compression to zlib@openssh.com if set to zlib" do
      config[:compression] = "zlib"

      transport[:compression].must_equal "zlib@openssh.com"
    end

    it "sets :compression_level to 6 by default" do
      transport[:compression_level].must_equal 0
    end

    it "sets :compression_level to 6 if :compression is set to true" do
      config[:compression] = true

      transport[:compression_level].must_equal 6
    end

    it "sets :keepalive to true by default" do
      transport[:keepalive].must_equal true
    end

    it "sets :keepalive_interval to 60 by default" do
      transport[:keepalive_interval].must_equal 60
    end

    it "sets :connection_timeout to 15 by default" do
      transport[:connection_timeout].must_equal 15
    end

    it "sets :connection_retries to 5 by default" do
      transport[:connection_retries].must_equal 5
    end

    it "sets :connection_retry_sleep to 1 by default" do
      transport[:connection_retry_sleep].must_equal 1
    end

    it "sets :max_wait_until_ready to 600 by default" do
      transport[:max_wait_until_ready].must_equal 600
    end

    it "sets :ssh_key to nil by default" do
      transport[:ssh_key].must_be_nil
    end

    it "sets :ssh_key_only to nil by default" do
      transport[:ssh_key_only].must_be_nil
    end

    it "expands :ssh_path path if set" do
      config[:kitchen_root] = "/rooty"
      config[:ssh_key] = "my_key"

      transport[:ssh_key].must_equal os_safe_root_path("/rooty/my_key")
    end

    it "sets :max_ssh_sessions to 9 by default" do
      transport[:max_ssh_sessions].must_equal 9
    end

    it "sets :ssh_http_proxy to nil by default" do
      transport[:ssh_http_proxy].must_be_nil
    end

    it "sets :ssh_http_proxy_port to nil by default" do
      transport[:ssh_http_proxy_port].must_be_nil
    end

    it "sets :ssh_http_proxy_user to nil by default" do
      transport[:ssh_http_proxy_user].must_be_nil
    end

    it "sets :ssh_http_proxy_password to nil by default" do
      transport[:ssh_http_proxy_password].must_be_nil
    end

    it "sets :ssh_gateway to nil by default" do
      transport[:ssh_gateway].must_be_nil
    end

    it "sets :ssh_gateway_username to nil by default" do
      transport[:ssh_gateway_username].must_be_nil
    end

    it "sets :ssh_gateway_port to 22 by default" do
      transport[:ssh_gateway_port].must_equal 22
    end
  end

  describe "#connection" do
    let(:klass) { Kitchen::Transport::Ssh::Connection }
    let(:options_http_proxy) { Hash.new }
    let(:proxy_conn) do
      state[:ssh_http_proxy]        = "ssh_http_proxy_from_state"
      state[:ssh_http_proxy_port]   = "ssh_http_proxy_port_from_state"
      options_http_proxy[:user]     = state[:ssh_http_proxy_user]
      options_http_proxy[:password] = state[:ssh_http_proxy_password]
      Net::SSH::Proxy::HTTP.new(state[:ssh_http_proxy], state[:ssh_http_proxy_port], options_http_proxy)
    end

    # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
    def self.common_connection_specs
      before do
        Net::SSH::Proxy::HTTP.stubs(:new).returns(proxy_conn)
      end

      it "returns a Kitchen::Transport::Ssh::Connection object" do
        transport.connection(state).must_be_kind_of klass
      end

      it "sets the :logger to the transport's logger" do
        klass.expects(:new).with do |hash|
          hash[:logger] == logger
        end

        make_connection
      end

      it "sets the :user_known_hosts_file to /dev/null" do
        klass.expects(:new).with do |hash|
          hash[:user_known_hosts_file] == "/dev/null"
        end

        make_connection
      end

      it "sets the :verify_host_key flag to false" do
        klass.expects(:new).with do |hash|
          hash[:verify_host_key] == false
        end

        make_connection
      end

      it "sets :hostname from config" do
        config[:hostname] = "host_from_config"

        klass.expects(:new).with do |hash|
          hash[:hostname] == "host_from_config"
        end

        make_connection
      end

      it "sets :hostname from state over config data" do
        state[:hostname] = "host_from_state"
        config[:hostname] = "host_from_config"

        klass.expects(:new).with do |hash|
          hash[:hostname] == "host_from_state"
        end

        make_connection
      end

      it "sets :port from config" do
        config[:port] = "port_from_config"

        klass.expects(:new).with do |hash|
          hash[:port] == "port_from_config"
        end

        make_connection
      end

      it "sets :port from state over config data" do
        state[:port] = "port_from_state"
        config[:port] = "port_from_config"

        klass.expects(:new).with do |hash|
          hash[:port] == "port_from_state"
        end

        make_connection
      end

      it "sets :username from config" do
        config[:username] = "user_from_config"

        klass.expects(:new).with do |hash|
          hash[:username] == "user_from_config"
        end

        make_connection
      end

      it "sets :username from state over config data" do
        state[:username] = "user_from_state"
        config[:username] = "user_from_config"

        klass.expects(:new).with do |hash|
          hash[:username] == "user_from_state"
        end

        make_connection
      end

      it "sets :compression from config" do
        config[:compression] = "none"

        klass.expects(:new).with do |hash|
          hash[:compression] == false
        end

        make_connection
      end

      it "sets :compression from state over config data" do
        state[:compression] = "none"
        config[:compression] = "zlib"

        klass.expects(:new).with do |hash|
          hash[:compression] == "none"
        end

        make_connection
      end

      it "sets :compression_level from config" do
        config[:compression_level] = 9999

        klass.expects(:new).with do |hash|
          hash[:compression_level] == 9999
        end

        make_connection
      end

      it "sets :compression_level from state over config data" do
        state[:compression_level] = 9999
        config[:compression_level] = 1111

        klass.expects(:new).with do |hash|
          hash[:compression_level] == 9999
        end

        make_connection
      end

      it "sets :timeout from :connection_timeout in config" do
        config[:connection_timeout] = "timeout_from_config"

        klass.expects(:new).with do |hash|
          hash[:timeout] == "timeout_from_config"
        end

        make_connection
      end

      it "sets :timeout from :connection_timeout in state over config data" do
        state[:connection_timeout] = "timeout_from_state"
        config[:connection_timeout] = "timeout_from_config"

        klass.expects(:new).with do |hash|
          hash[:timeout] == "timeout_from_state"
        end

        make_connection
      end

      it "sets :keepalive from config" do
        config[:keepalive] = "keepalive_from_config"

        klass.expects(:new).with do |hash|
          hash[:keepalive] == "keepalive_from_config"
        end

        make_connection
      end

      it "sets :keepalive from state over config data" do
        state[:keepalive] = "keepalive_from_state"
        config[:keepalive] = "keepalive_from_config"

        klass.expects(:new).with do |hash|
          hash[:keepalive] == "keepalive_from_state"
        end

        make_connection
      end

      it "sets :keepalive_interval from config" do
        config[:keepalive_interval] = "interval_from_config"

        klass.expects(:new).with do |hash|
          hash[:keepalive_interval] == "interval_from_config"
        end

        make_connection
      end

      it "sets :keepalive_interval from state over config data" do
        state[:keepalive_interval] = "interval_from_state"
        config[:keepalive_interval] = "interval_from_config"

        klass.expects(:new).with do |hash|
          hash[:keepalive_interval] == "interval_from_state"
        end

        make_connection
      end

      it "sets :connection_retries from config" do
        config[:connection_retries] = "retries_from_config"

        klass.expects(:new).with do |hash|
          hash[:connection_retries] == "retries_from_config"
        end

        make_connection
      end

      it "sets :connection_retries from state over config data" do
        state[:connection_retries] = "retries_from_state"
        config[:connection_retries] = "retries_from_config"

        klass.expects(:new).with do |hash|
          hash[:connection_retries] == "retries_from_state"
        end

        make_connection
      end

      it "sets :connection_retry_sleep from config" do
        config[:connection_retry_sleep] = "sleep_from_config"

        klass.expects(:new).with do |hash|
          hash[:connection_retry_sleep] == "sleep_from_config"
        end

        make_connection
      end

      it "sets :connection_retry_sleep from state over config data" do
        state[:connection_retry_sleep] = "sleep_from_state"
        config[:connection_retry_sleep] = "sleep_from_config"

        klass.expects(:new).with do |hash|
          hash[:connection_retry_sleep] == "sleep_from_state"
        end

        make_connection
      end

      it "sets :max_wait_until_ready from config" do
        config[:max_wait_until_ready] = "max_from_config"

        klass.expects(:new).with do |hash|
          hash[:max_wait_until_ready] == "max_from_config"
        end

        make_connection
      end

      it "sets :max_wait_until_ready from state over config data" do
        state[:max_wait_until_ready] = "max_from_state"
        config[:max_wait_until_ready] = "max_from_config"

        klass.expects(:new).with do |hash|
          hash[:max_wait_until_ready] == "max_from_state"
        end

        make_connection
      end

      it "sets :keys_only to true if :ssh_key is set in config" do
        config[:ssh_key] = "ssh_key_from_config"

        klass.expects(:new).with do |hash|
          hash[:keys_only] == true
        end

        make_connection
      end

      it "sets :auth_methods to only publickey if :ssh_key is set in config" do
        config[:ssh_key] = "ssh_key_from_config"

        klass.expects(:new).with do |hash|
          hash[:auth_methods] == ["publickey"]
        end

        make_connection
      end

      it "sets :keys_only to true if :ssh_key is set in state" do
        state[:ssh_key] = "ssh_key_from_config"
        config[:ssh_key] = false

        klass.expects(:new).with do |hash|
          hash[:keys_only] == true
        end

        make_connection
      end

      it "sets :proxy to proxy if :ssh_http_proxy is set in state" do
        config[:ssh_http_proxy] = true

        klass.expects(:new).with do |hash|
          hash[:proxy] == proxy_conn
        end

        make_connection
      end

      it "sets :keys to an array if :ssh_key is set in config" do
        config[:kitchen_root] = "/r"
        config[:ssh_key] = "ssh_key_from_config"

        klass.expects(:new).with do |hash|
          hash[:keys] == [os_safe_root_path("/r/ssh_key_from_config")]
        end

        make_connection
      end

      it "sets :keys to an array if :ssh_key is set in state" do
        state[:ssh_key] = "ssh_key_from_state"
        config[:ssh_key] = "ssh_key_from_config"

        klass.expects(:new).with do |hash|
          hash[:keys] == ["ssh_key_from_state"]
        end

        make_connection
      end

      it "does not set :keys_only if :ssh_key is set in config but password is set" do
        config[:ssh_key] = "ssh_key_from_config"
        config[:password] = "password"

        klass.expects(:new).with do |hash|
          hash[:keys_only].nil?
        end

        make_connection
      end

      it "does not set :auth_methods if :ssh_key is set in config but password is set" do
        config[:ssh_key] = "ssh_key_from_config"
        config[:password] = "password"

        klass.expects(:new).with do |hash|
          hash[:auth_methods].nil?
        end

        make_connection
      end

      it "does not set :keys_only if :ssh_key is set in state but password is set" do
        state[:ssh_key] = "ssh_key_from_config"
        config[:ssh_key] = false
        config[:password] = "password"

        klass.expects(:new).with do |hash|
          hash[:keys_only].nil?
        end

        make_connection
      end

      it "does not set :keys to an array if :ssh_key is set in config but password is set" do
        config[:kitchen_root] = "/r"
        config[:ssh_key] = "ssh_key_from_config"
        config[:password] = "password"

        klass.expects(:new).with do |hash|
          hash[:keys].nil?
        end

        make_connection
      end

      it "does not set :keys to an array if :ssh_key is set in state but password is set" do
        state[:ssh_key] = "ssh_key_from_state"
        config[:ssh_key] = "ssh_key_from_config"
        config[:password] = "password"

        klass.expects(:new).with do |hash|
          hash[:keys].nil?
        end

        make_connection
      end

      it "sets :auth_methods to only publickey if :ssh_key_only is set in config" do
        config[:ssh_key_only] = true

        klass.expects(:new).with do |hash|
          hash[:auth_methods] == ["publickey"]
        end

        make_connection
      end

      it "passes in :password if set in config" do
        config[:password] = "password_from_config"

        klass.expects(:new).with do |hash|
          hash[:password] == "password_from_config"
        end

        make_connection
      end

      it "passes in :password from state over config data" do
        state[:password] = "password_from_state"
        config[:password] = "password_from_config"

        klass.expects(:new).with do |hash|
          hash[:password] == "password_from_state"
        end

        make_connection
      end

      it "passes in :forward_agent if set in config" do
        config[:forward_agent] = "forward_agent_from_config"

        klass.expects(:new).with do |hash|
          hash[:forward_agent] == "forward_agent_from_config"
        end

        make_connection
      end

      it "passes in :forward_agent from state over config data" do
        state[:forward_agent] = "forward_agent_from_state"
        config[:forward_agent] = "forward_agent_from_config"

        klass.expects(:new).with do |hash|
          hash[:forward_agent] == "forward_agent_from_state"
        end

        make_connection
      end

      it "returns the same connection when called again with same state" do
        first_connection  = make_connection(state)
        second_connection = make_connection(state)

        first_connection.object_id.must_equal second_connection.object_id
      end

      it "logs a debug message when the connection is reused" do
        make_connection(state)
        make_connection(state)

        logged_output.string.lines.count do |l|
          l =~ debug_line_with("[SSH] reusing existing connection ")
        end.must_equal 1
      end

      it "returns a new connection when called again if state differs" do
        first_connection  = make_connection(state)
        second_connection = make_connection(state.merge(port: 9000))

        first_connection.object_id.wont_equal second_connection.object_id
      end

      it "closes first connection when a second is created" do
        first_connection = make_connection(state)
        first_connection.expects(:close)

        make_connection(state.merge(port: 9000))
      end

      it "logs a debug message a second connection is created" do
        make_connection(state)
        make_connection(state.merge(port: 9000))

        logged_output.string.lines.count do |l|
          l =~ debug_line_with("[SSH] shutting previous connection ")
        end.must_equal 1
      end
    end
    # rubocop:enable Metrics/MethodLength, Metrics/AbcSize

    describe "called without a block" do
      def make_connection(s = state)
        transport.connection(s)
      end

      common_connection_specs
    end

    describe "called with a block" do
      def make_connection(s = state)
        transport.connection(s) do |conn|
          conn
        end
      end

      common_connection_specs
    end
  end

  def debug_line_with(msg)
    /^D, .* : #{Regexp.escape(msg)}/
  end
end

describe Kitchen::Transport::Ssh::Connection do
  include Net::SSH::Test
  # sadly, Net:SSH::Test includes a #connection method so we'll alias this one
  # before redefining it
  alias_method :net_ssh_connection, :connection

  let(:logged_output)   { StringIO.new }
  let(:logger)          { Logger.new(logged_output) }
  let(:conn)            { Net::SSH::Test::Extensions::IO.with_test_extension { net_ssh_connection } }

  let(:options) do
    {
      logger: logger,
      username: "me",
      hostname: "foo",
      port: 22,
      max_ssh_sessions: 9,
    }
  end

  let(:connection) do
    Kitchen::Transport::Ssh::Connection.new(options)
  end

  before do
    logger.level = Logger::DEBUG
    Net::SSH.stubs(:start).returns(conn)
  end

  describe "establishing a connection" do
    [
      Errno::EACCES, Errno::EALREADY, Errno::EADDRINUSE, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
      Errno::ECONNRESET, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::EPIPE,
      Net::SSH::Disconnect, Net::SSH::AuthenticationFailed, Net::SSH::ConnectionTimeout,
      Timeout::Error
    ].each do |klass|
      describe "raising #{klass}" do
        before do
          Net::SSH.stubs(:start).raises(klass)
          options[:connection_retries] = 3
          options[:connection_retry_sleep] = 7
          connection.stubs(:sleep)
        end

        it "raises an SshFailed exception" do
          e = proc do
            connection.execute("nope")
          end.must_raise Kitchen::Transport::SshFailed
          e.message.must_match regexify("SSH session could not be established")
        end

        it "attempts to connect :connection_retries times" do
          begin
            connection.execute("nope")
          rescue # rubocop:disable Lint/HandleExceptions
            # the raise is not what is being tested here, rather its side-effect
          end

          logged_output.string.lines.count do |l|
            l =~ debug_line("[SSH] opening connection to me@foo<{:port=>22}>")
          end.must_equal 3
        end

        it "sleeps for :connection_retry_sleep seconds between retries" do
          connection.unstub(:sleep)
          connection.expects(:sleep).with(7).twice

          begin
            connection.execute("nope")
          rescue # rubocop:disable Lint/HandleExceptions
            # the raise is not what is being tested here, rather its side-effect
          end
        end

        it "logs the first 2 retry failures on info" do
          begin
            connection.execute("nope")
          rescue # rubocop:disable Lint/HandleExceptions
            # the raise is not what is being tested here, rather its side-effect
          end

          logged_output.string.lines.count do |l|
            l =~ info_line_with(
              "[SSH] connection failed, retrying in 7 seconds")
          end.must_equal 2
        end

        it "logs the last retry failures on warn" do
          begin
            connection.execute("nope")
          rescue # rubocop:disable Lint/HandleExceptions
            # the raise is not what is being tested here, rather its side-effect
          end

          logged_output.string.lines.count do |l|
            l =~ warn_line_with("[SSH] connection failed, terminating ")
          end.must_equal 1
        end
      end
    end
  end

  describe "#close" do
    before do
      story do |script|
        channel = script.opens_channel
        channel.sends_request_pty
        channel.sends_exec("doit")
        channel.gets_data("ok\n")
        channel.gets_exit_status(0)
        channel.gets_close
        channel.sends_close
      end
    end

    it "logger displays closing connection on debug" do
      conn.expects(:close)

      assert_scripted do
        connection.execute("doit")
        connection.close
      end

      logged_output.string.must_match debug_line(
        "[SSH] closing connection to me@foo<{:port=>22}>"
      )
    end

    it "only closes the connection once for multiple calls" do
      conn.expects(:close).once

      assert_scripted do
        connection.execute("doit")
        connection.close
        connection.close
        connection.close
      end
    end
  end

  describe "#execute" do
    describe "for a successful command" do
      before do
        story do |script|
          channel = script.opens_channel
          channel.sends_request_pty
          channel.sends_exec("doit")
          channel.gets_data("ok\n")
          channel.gets_extended_data("some stderr stuffs\n")
          channel.gets_exit_status(0)
          channel.gets_close
          channel.sends_close
        end
      end

      it "logger displays command on debug" do
        assert_scripted { connection.execute("doit") }

        logged_output.string.must_match debug_line(
          "[SSH] me@foo<{:port=>22}> (doit)"
        )
      end

      it "logger displays establishing connection on debug" do
        assert_scripted { connection.execute("doit") }

        logged_output.string.must_match debug_line(
          "[SSH] opening connection to me@foo<{:port=>22}>"
        )
      end

      it "logger captures stdout" do
        assert_scripted { connection.execute("doit") }

        logged_output.string.must_match(/^ok$/)
      end

      it "logger captures stderr" do
        assert_scripted { connection.execute("doit") }

        logged_output.string.must_match(/^some stderr stuffs$/)
      end
    end

    describe "for a failed command" do
      before do
        story do |script|
          channel = script.opens_channel
          channel.sends_request_pty
          channel.sends_exec("doit")
          channel.gets_data("nope\n")
          channel.gets_extended_data("youdead\n")
          channel.gets_exit_status(42)
          channel.gets_close
          channel.sends_close
        end
      end

      it "logger displays command on debug" do
        begin
          assert_scripted { connection.execute("doit") }
        rescue # rubocop:disable Lint/HandleExceptions
          # the raise is not what is being tested here, rather its side-effect
        end

        logged_output.string.must_match debug_line(
          "[SSH] me@foo<{:port=>22}> (doit)"
        )
      end

      it "logger displays establishing connection on debug" do
        begin
          assert_scripted { connection.execute("doit") }
        rescue # rubocop:disable Lint/HandleExceptions
          # the raise is not what is being tested here, rather its side-effect
        end

        logged_output.string.must_match debug_line(
          "[SSH] opening connection to me@foo<{:port=>22}>"
        )
      end

      it "logger captures stdout" do
        begin
          assert_scripted { connection.execute("doit") }
        rescue # rubocop:disable Lint/HandleExceptions
          # the raise is not what is being tested here, rather its side-effect
        end

        logged_output.string.must_match(/^nope$/)
      end

      it "logger captures stderr" do
        begin
          assert_scripted { connection.execute("doit") }
        rescue # rubocop:disable Lint/HandleExceptions
          # the raise is not what is being tested here, rather its side-effect
        end

        logged_output.string.must_match(/^youdead$/)
      end

      it "raises an SshFailed exception" do
        err = proc do
          assert_scripted { connection.execute("doit") }
        end.must_raise Kitchen::Transport::SshFailed
        err.message.must_equal "SSH exited (42) for command: [doit]"
      end

      it "returns the exit code with an SshFailed exception" do
        begin
          assert_scripted { connection.execute("doit") }
        rescue Kitchen::Transport::SshFailed => e
          e.exit_code.must_equal 42
        end
      end
    end

    describe "for an interrupted command" do
      let(:conn) { mock("session") }

      before do
        Net::SSH.stubs(:start).returns(conn)
      end

      it "raises SshFailed when an SSH exception is raised" do
        conn.stubs(:open_channel).raises(Net::SSH::Exception)

        e = proc do
          connection.execute("nope")
        end.must_raise Kitchen::Transport::SshFailed
        e.message.must_match regexify("SSH command failed")
      end
    end

    describe "for a nil command" do
      it "does not log on debug" do
        connection.execute(nil)

        logged_output.string.must_equal ""
      end
    end
  end

  describe "#login_command" do
    let(:login_command) { connection.login_command }
    let(:args)          { login_command.arguments.join(" ") }

    it "returns a LoginCommand" do
      login_command.must_be_instance_of Kitchen::LoginCommand
    end

    it "is an SSH command" do
      login_command.command.must_equal "ssh"
      args.must_match %r{ me@foo$}
    end

    it "sets the UserKnownHostsFile option" do
      args.must_match regexify("-o UserKnownHostsFile=/dev/null ")
    end

    it "sets the StrictHostKeyChecking option" do
      args.must_match regexify(" -o StrictHostKeyChecking=no ")
    end

    it "won't set IdentitiesOnly option by default" do
      args.wont_match regexify(" -o IdentitiesOnly=")
    end

    it "sets the IdentiesOnly option if :keys option is given" do
      options[:keys] = ["yep"]

      args.must_match regexify(" -o IdentitiesOnly=yes ")
    end

    it "sets the LogLevel option to VERBOSE if logger is set to debug" do
      logger.level = ::Logger::DEBUG
      options[:logger] = logger

      args.must_match regexify(" -o LogLevel=VERBOSE ")
    end

    it "sets the LogLevel option to ERROR if logger is not set to debug" do
      logger.level = ::Logger::INFO
      options[:logger] = logger

      args.must_match regexify(" -o LogLevel=ERROR ")
    end

    it "won't set the ForwardAgent option by default" do
      args.wont_match regexify(" -o ForwardAgent=")
    end

    it "sets the ForwardAgent option to yes if truthy" do
      options[:forward_agent] = "yep"

      args.must_match regexify(" -o ForwardAgent=yes")
    end

    it "sets the ForwardAgent option to no if falsey" do
      options[:forward_agent] = false

      args.must_match regexify(" -o ForwardAgent=no")
    end

    it "won't add any SSH keys by default" do
      args.wont_match regexify(" -i ")
    end

    it "sets SSH keys options if given" do
      options[:keys] = %w{one two}

      args.must_match regexify(" -i one ")
      args.must_match regexify(" -i two ")
    end

    it "sets the port option to 22 by default" do
      args.must_match regexify(" -p 22 ")
    end

    it "sets the port option" do
      options[:port] = 1234

      args.must_match regexify(" -p 1234 ")
    end
  end

  describe "#upload" do
    describe "for a file" do
      let(:content) { "a" * 1234 }

      let(:src) do
        file = Tempfile.new("file")
        file.write("a" * 1234)
        file.close
        FileUtils.chmod(0755, file.path)
        file
      end

      before do
        expect_scp_session("-t /tmp/remote") do |channel|
          file_mode = running_tests_on_windows? ? 0644 : 0755
          channel.gets_data("\0")
          channel.sends_data("C#{padded_octal_string(file_mode)} 1234 #{File.basename(src.path)}\n")
          channel.gets_data("\0")
          channel.sends_data("a" * 1234)
          channel.sends_data("\0")
          channel.gets_data("\0")
        end
      end

      after do
        src.unlink
      end

      it "uploads a file to remote over scp" do
        assert_scripted do
          connection.upload(src.path, "/tmp/remote")
        end
      end
    end

    describe "for a path" do
      before do
        @dir = Dir.mktmpdir("local")

        # Since File.chmod is a NOOP on Windows
        @tmp_dir_mode = running_tests_on_windows? ? 0755 : 0700
        @alpha_file_mode = running_tests_on_windows? ? 0644 : 0644
        @beta_file_mode = running_tests_on_windows? ? 0444 : 0555

        FileUtils.chmod(0700, @dir)
        File.open("#{@dir}/alpha", "wb") { |f| f.write("alpha-contents\n") }
        FileUtils.chmod(0644, "#{@dir}/alpha")
        FileUtils.mkdir_p("#{@dir}/subdir")
        FileUtils.chmod(0755, "#{@dir}/subdir")
        File.open("#{@dir}/subdir/beta", "wb") { |f| f.write("beta-contents\n") }
        FileUtils.chmod(0555, "#{@dir}/subdir/beta")
        File.open("#{@dir}/zulu", "wb") { |f| f.write("zulu-contents\n") }
        FileUtils.chmod(0444, "#{@dir}/zulu")

        expect_scp_session("-t -r /tmp/remote") do |channel|
          channel.gets_data("\0")
          channel.sends_data("D#{padded_octal_string(@tmp_dir_mode)} 0 #{File.basename(@dir)}\n")
          channel.gets_data("\0")
          channel.sends_data("C#{padded_octal_string(@alpha_file_mode)} 15 alpha\n")
          channel.gets_data("\0")
          channel.sends_data("alpha-contents\n")
          channel.sends_data("\0")
          channel.gets_data("\0")
          channel.sends_data("D0755 0 subdir\n")
          channel.gets_data("\0")
          channel.sends_data("C#{padded_octal_string(@beta_file_mode)} 14 beta\n")
          channel.gets_data("\0")
          channel.sends_data("beta-contents\n")
          channel.sends_data("\0")
          channel.gets_data("\0")
          channel.sends_data("E\n")
          channel.gets_data("\0")
          channel.sends_data("C0444 14 zulu\n")
          channel.gets_data("\0")
          channel.sends_data("zulu-contents\n")
          channel.sends_data("\0")
          channel.gets_data("\0")
          channel.sends_data("E\n")
          channel.gets_data("\0")
        end
      end

      after do
        FileUtils.remove_entry_secure(@dir)
      end

      it "uploads a file to remote over scp" do
        with_sorted_dir_entries do
          assert_scripted { connection.upload(@dir, "/tmp/remote") }
        end
      end
    end

    describe "for a failed upload" do
      let(:conn) { mock("session") }

      before do
        Net::SSH.stubs(:start).returns(conn)
      end

      it "raises SshFailed when an SSH exception is raised" do
        conn.stubs(:scp).raises(Net::SSH::Exception)

        e = proc do
          connection.upload("nope", "fail")
        end.must_raise Kitchen::Transport::SshFailed
        e.message.must_match regexify("SCP upload failed")
      end
    end
  end

  describe "#download" do
    let(:conn) { mock("session") }
    let(:scp) { mock("scp") }

    before do
      Net::SSH.stubs(:start).returns(conn)
      conn.stubs(:scp).returns(scp)
      @local_parent = Dir.mktmpdir("dir")
      @local = File.join(@local_parent, "local")
    end

    after do
      FileUtils.remove_entry_secure(@local_parent)
    end

    describe "for a file" do
      it "downloads a file from a remote over scp" do
        FileUtils.expects(:mkdir_p).with(@local_parent)
        scp.expects(:download!).with("/remote", @local)

        connection.download("/remote", @local)
      end
    end

    describe "for a list of files" do
      it "downloads the files from a remote over scp" do
        FileUtils.expects(:mkdir_p).with(@local_parent)
        scp.expects(:download!).with("/remote-1", @local)
        scp.expects(:download!).with("/remote-2", @local)

        connection.download(["/remote-1", "/remote-2"], @local)
      end
    end

    describe "for a directory" do
      it "downloads the directory from a remote over scp" do
        FileUtils.expects(:mkdir_p).with(@local_parent)
        scp.expects(:download!).with("/remote-dir", @local).raises(Net::SCP::Error)
        scp.expects(:download!).with("/remote-dir", @local, recursive: true)

        connection.download("/remote-dir", @local)
      end
    end

    describe "for a file that does not exist" do
      it "logs a warning" do
        FileUtils.expects(:mkdir_p).with(@local_parent)
        scp.expects(:download!).with("/remote", @local).raises(Net::SCP::Error)
        scp.expects(:download!).with("/remote", @local, recursive: true)
          .raises(Net::SCP::Error)

        connection.download("/remote", @local)

        logged_output.string.lines.count do |l|
          l =~ warn_line_with(
            "SCP download failed for file or directory '/remote', perhaps it does not exist?"
          )
        end.must_equal 1
      end
    end

    describe "for a failed download" do
      it "raises SshFailed when an SSH exception is raised" do
        conn.stubs(:scp).raises(Net::SSH::Exception)

        e = proc do
          connection.download("nope", "fail")
        end.must_raise Kitchen::Transport::SshFailed
        e.message.must_match regexify("SCP download failed")
      end
    end
  end

  describe "#wait_until_ready" do
    before do
      options[:max_wait_until_ready] = 300
      connection.stubs(:sleep)
    end

    describe "when failing to connect" do
      before do
        Net::SSH.stubs(:start).raises(Errno::ECONNREFUSED)
      end

      it "attempts to connect :max_wait_until_ready / 3 times if failing" do
        begin
          connection.wait_until_ready
        rescue # rubocop:disable Lint/HandleExceptions
          # the raise is not what is being tested here, rather its side-effect
        end

        logged_output.string.lines.count do |l|
          l =~ info_line_with(
            "Waiting for SSH service on foo:22, retrying in 3 seconds")
        end.must_equal((300 / 3) - 1)
        logged_output.string.lines.count do |l|
          l =~ debug_line_with("[SSH] connection failed ")
        end.must_equal((300 / 3) - 1)
        logged_output.string.lines.count do |l|
          l =~ warn_line_with("[SSH] connection failed, terminating ")
        end.must_equal 1
      end

      it "sleeps for 3 seconds between retries" do
        connection.unstub(:sleep)
        connection.expects(:sleep).with(3).times((300 / 3) - 1)

        begin
          connection.wait_until_ready
        rescue # rubocop:disable Lint/HandleExceptions
          # the raise is not what is being tested here, rather its side-effect
        end
      end
    end

    describe "when connection is successful" do
      before do
        story do |script|
          channel = script.opens_channel
          channel.sends_request_pty
          channel.sends_exec("echo '[SSH] Established'")
          channel.gets_data("[SSH] Established\n")
          channel.gets_exit_status(0)
          channel.gets_close
          channel.sends_close
        end
      end

      it "executes an ping command string to ensure working" do
        assert_scripted { connection.wait_until_ready }
      end

      it "logger captures stdout" do
        assert_scripted { connection.wait_until_ready }

        logged_output.string.must_match(/^\[SSH\] Established$/)
      end
    end
  end

  def expect_scp_session(args)
    story do |script|
      channel = script.opens_channel
      channel.sends_exec("scp #{args}")
      yield channel if block_given?
      channel.sends_eof
      channel.gets_exit_status(0)
      channel.gets_eof
      channel.gets_close
      channel.sends_close
    end
  end

  def debug_line(msg)
    /^D, .* : #{Regexp.escape(msg)}$/
  end

  def debug_line_with(msg)
    /^D, .* : #{Regexp.escape(msg)}/
  end

  def info_line_with(msg)
    /^I, .* : #{Regexp.escape(msg)}/
  end

  def regexify(string)
    Regexp.new(Regexp.escape(string))
  end

  def warn_line_with(msg)
    /^W, .* : #{Regexp.escape(msg)}/
  end
end
