require 'spec_helper'

describe Aptible::CLI::Helpers::Ssh do
  let!(:work_dir) { Dir.mktmpdir }
  after { FileUtils.remove_entry work_dir }
  around { |example| ClimateControl.modify(HOME: work_dir) { example.run } }

  subject { Class.new.send(:include, described_class).new }

  let(:ssh_dir) { File.join(work_dir, '.aptible', 'ssh') }
  let(:config_file) { File.join(ssh_dir, 'config') }
  let(:private_key_file) { File.join(ssh_dir, 'id_rsa') }
  let(:public_key_file) { "#{private_key_file}.pub" }

  describe '#ensure_ssh_dir!' do
    it 'creates the directory' do
      subject.send(:ensure_ssh_dir!)
      expect(Dir.exist?(ssh_dir)).to be_truthy
    end

    it 'works if the directory already exists' do
      subject.send(:ensure_ssh_dir!)
      subject.send(:ensure_ssh_dir!)
    end
  end

  describe '#ensure_config!' do
    before { subject.send(:ensure_ssh_dir!) }

    it 'creates the config file' do
      subject.send(:ensure_config!)
      expect(File.exist?(config_file)).to be_truthy
    end
  end

  describe '#ensure_key!' do
    before { subject.send(:ensure_ssh_dir!) }

    it 'creates the key if it does not exist' do
      subject.send(:ensure_key!)

      expect(File.exist?(private_key_file)).to be_truthy
      expect(File.exist?(public_key_file)).to be_truthy
    end

    it 'does not recreate the key if it already exists' do
      subject.send(:ensure_key!)
      k1 = File.read(private_key_file)
      subject.send(:ensure_key!)
      k2 = File.read(private_key_file)

      expect(k2).to eq(k1)
    end

    it 'recreates the key if either part is missing' do
      subject.send(:ensure_key!)
      k1 = File.read(private_key_file)
      File.delete(private_key_file)

      subject.send(:ensure_key!)
      k2 = File.read(private_key_file)
      File.delete(public_key_file)

      subject.send(:ensure_key!)
      k3 = File.read(private_key_file)

      expect(k2).not_to eq(k1)
      expect(k3).not_to eq(k2)
    end
  end

  describe '#with_ssh_cmd' do
    it 'delegates and yields usable SSH parameters' do
      operation = double('operation')
      connection = double('connection')

      expect(operation).to receive(:with_ssh_cmd).with(private_key_file)
        .and_yield(['some-ssh'], connection)

      has_yielded = false

      subject.with_ssh_cmd(operation) do |cmd, c|
        expect(cmd).to include('some-ssh')
        expect(cmd).to include(config_file)
        expect(c).to be(connection)
        has_yielded = true
      end

      expect(has_yielded).to be_truthy
    end
  end

  describe '#spawn_passthrough' do
    let(:bins) { File.expand_path('../../../../script', __FILE__) }
    let(:ruby) { Gem.win_platform? ? 'ruby.exe' : 'ruby' }
    let(:wrapper) { [ruby, File.join(bins, 'ssh-spawn')] }
    let(:exit_with) { [ruby, File.join(bins, 'exit-with')] }
    let(:sigint) { [ruby, File.join(bins, 'pid-signal')] }
    let(:setpgid) { [ruby, File.join(bins, 'setpgid')] }

    let(:cleanup) { [] }

    after do
      cleanup.each do |pid|
        begin
          Process.kill(:SIGKILL, -pid)
        rescue Errno::ESRCH, Errno::EINVAL
        end
      end
    end

    def spawn_with_cleanup(*args)
      kw = Gem.win_platform? ? { new_pgroup: true } : { pgroup: true }
      Process.spawn(*args, **kw).tap { |pid| cleanup << pid }
    end

    def wait_for_file(file)
      50.times do
        return if File.exist?(file)
        sleep 0.1
      end

      raise "File never showed up: #{file}"
    end

    def wait_for_pid(pid, timeout = 5)
      (timeout * 10).times do
        _, status = Process.wait2(pid, Process::WNOHANG)
        return status if status
        sleep 0.1
      end

      raise "PID never exited: #{pid}"
    end

    [0, 1].each do |c|
      it "returns the command exit code (#{c})" do
        pid = spawn_with_cleanup(*wrapper, *exit_with, c.to_s)
        status = wait_for_pid(pid)
        expect(status.exitstatus).to eq(c)
      end
    end

    context 'signals' do
      # Don't run these on Windows: sending SIGINT will send it to the entire
      # console group, which includes the process running the specs.
      before { skip 'Windows' if Gem.win_platform? }

      it 'returns 128 + signal number when signalled' do
        Dir.mktmpdir do |dir|
          pid_file = File.join(dir, 'pid')
          pid = spawn_with_cleanup(*wrapper, *sigint, pid_file)
          wait_for_file(pid_file)

          child_pid = Integer(File.read(pid_file).chomp)
          Process.kill('INT', child_pid)

          status = wait_for_pid(pid)

          if Gem.win_platform?
            expect(status.exitstatus).not_to eq(0)
          else
            expect(status.exitstatus).to eq(128 + Signal.list.fetch('INT'))
          end
        end
      end

      it 'does not proxy SIGINT when part of the same process group' do
        Dir.mktmpdir do |dir|
          pid_file = File.join(dir, 'pid')
          pid = spawn_with_cleanup(*wrapper, *sigint, pid_file)
          wait_for_file(pid_file)

          child_pid = Integer(File.read(pid_file).chomp)
          expect(Process.getpgid(child_pid)).to eq(Process.getpgid(pid))
          Process.kill('INT', pid)

          expect { wait_for_pid(pid, 2) }.to raise_error(/never exited/im)
        end
      end

      it 'proxies SIGINT when process groups are different' do
        Dir.mktmpdir do |dir|
          pid_file = File.join(dir, 'pid')
          pid = spawn_with_cleanup(*wrapper, *setpgid, *sigint, pid_file)
          wait_for_file(pid_file)

          child_pid = Integer(File.read(pid_file).chomp)
          expect(Process.getpgid(child_pid)).not_to eq(Process.getpgid(pid))
          Process.kill('INT', pid)

          status = wait_for_pid(pid)
          expect(status.exitstatus).to eq(128 + Signal.list.fetch('INT'))
        end
      end

      it 'does not crash when receiving SIGINT concurrently' do
        Dir.mktmpdir do |dir|
          pid_file = File.join(dir, 'pid')
          pid = spawn_with_cleanup(*wrapper, *sigint, pid_file)
          wait_for_file(pid_file)

          child_pid = Integer(File.read(pid_file).chomp)
          expect(Process.getpgid(child_pid)).to eq(Process.getpgid(pid))
          Process.kill('INT', -pid)

          status = wait_for_pid(pid)
          expect(status.exitstatus).to eq(128 + Signal.list.fetch('INT'))
        end
      end
    end
  end
end