require 'spec_helper'
require 'puppet/ssl'
require 'puppet_spec/ssl'

describe Puppet::SSL::Validator::DefaultValidator, unless: Puppet::Util::Platform.jruby? do
  include PuppetSpec::Files
  let(:ssl_context) do
    mock('OpenSSL::X509::StoreContext')
  end

  before(:all) do
    @pki = PuppetSpec::SSL.create_chained_pki
  end

  let(:ca_path) do
    Puppet[:ssl_client_ca_auth] || Puppet[:localcacert]
  end

  let(:ssl_host) do
    stub('ssl_host',
         :ssl_store => nil,
         :certificate => stub('cert', :content => nil),
         :key => stub('key', :content => nil))
  end

  subject do
    described_class.new(ca_path)
  end

  before :each do
    subject.stubs(:read_file).returns(@pki[:root_cert].to_s)
  end

  describe '#call' do
    before :each do
      ssl_context.stubs(:current_cert).returns(*cert_chain_in_callback_order)
      ssl_context.stubs(:chain).returns(cert_chain)
    end

    context 'When pre-verification is not OK' do
      context 'and the ssl_context is in an error state' do
        let(:root_subject) { @pki[:root_cert].subject.to_s }
        let(:code) { OpenSSL::X509::V_ERR_INVALID_CA }

        it 'rejects the connection' do
          ssl_context.stubs(:error_string).returns("Something went wrong")
          ssl_context.stubs(:error).returns(code)

          expect(subject.call(false, ssl_context)).to eq(false)
        end

        it 'makes the error available via #verify_errors' do
          ssl_context.stubs(:error_string).returns("Something went wrong")
          ssl_context.stubs(:error).returns(code)

          subject.call(false, ssl_context)
          expect(subject.verify_errors).to eq(["Something went wrong for #{root_subject}"])
        end

        it 'uses a generic message if error_string is nil' do
          ssl_context.stubs(:error_string).returns(nil)
          ssl_context.stubs(:error).returns(code)

          subject.call(false, ssl_context)
          expect(subject.verify_errors).to eq(["OpenSSL error #{code} for #{root_subject}"])
        end

        it 'uses 0 for nil error codes' do
          ssl_context.stubs(:error_string).returns("Something went wrong")
          ssl_context.stubs(:error).returns(nil)

          subject.call(false, ssl_context)
          expect(subject.verify_errors).to eq(["Something went wrong for #{root_subject}"])
        end

        context "when CRL is not yet valid" do
          before :each do
            ssl_context.stubs(:error_string).returns("CRL is not yet valid")
            ssl_context.stubs(:error).returns(OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID)
          end

          it 'rejects nil CRL' do
            ssl_context.stubs(:current_crl).returns(nil)

            expect(subject.call(false, ssl_context)).to eq(false)
            expect(subject.verify_errors).to eq(["CRL is not yet valid"])
          end

          it 'includes the CRL issuer in the verify error message' do
            crl = OpenSSL::X509::CRL.new
            crl.issuer = OpenSSL::X509::Name.new([['CN','Puppet CA: puppetmaster.example.com']])
            crl.last_update = Time.now + 24 * 60 * 60
            ssl_context.stubs(:current_crl).returns(crl)

            subject.call(false, ssl_context)
            expect(subject.verify_errors).to eq(["CRL is not yet valid for /CN=Puppet CA: puppetmaster.example.com"])
          end

          it 'rejects CRLs whose last_update time is more than 5 minutes in the future' do
            crl = OpenSSL::X509::CRL.new
            crl.issuer = OpenSSL::X509::Name.new([['CN','Puppet CA: puppetmaster.example.com']])
            crl.last_update = Time.now + 24 * 60 * 60
            ssl_context.stubs(:current_crl).returns(crl)

            expect(subject.call(false, ssl_context)).to eq(false)
          end

          it 'accepts CRLs whose last_update time is 10 seconds in the future' do
            crl = OpenSSL::X509::CRL.new
            crl.issuer = OpenSSL::X509::Name.new([['CN','Puppet CA: puppetmaster.example.com']])
            crl.last_update = Time.now + 10
            ssl_context.stubs(:current_crl).returns(crl)

            expect(subject.call(false, ssl_context)).to eq(true)
          end
        end
      end
    end

    context 'When pre-verification is OK' do
      context 'and the ssl_context is in an error state' do
        before :each do
          ssl_context.stubs(:error_string).returns("Something went wrong")
        end

        it 'does not make the error available via #verify_errors' do
          subject.call(true, ssl_context)
          expect(subject.verify_errors).to eq([])
        end
      end

      context 'and the chain is valid' do
        it 'is true for each CA certificate in the chain' do
          (cert_chain.length - 1).times do
            expect(subject.call(true, ssl_context)).to be_truthy
          end
        end

        it 'is true for the SSL certificate ending the chain' do
          (cert_chain.length - 1).times do
            subject.call(true, ssl_context)
          end
          expect(subject.call(true, ssl_context)).to be_truthy
        end
      end

      context 'and the chain is invalid' do
        before :each do
          subject.stubs(:read_file).returns(@pki[:unrevoked_leaf_node_cert])
        end

        it 'is true for each CA certificate in the chain' do
          (cert_chain.length - 1).times do
            expect(subject.call(true, ssl_context)).to be_truthy
          end
        end

        it 'is false for the SSL certificate ending the chain' do
          (cert_chain.length - 1).times do
            subject.call(true, ssl_context)
          end
          expect(subject.call(true, ssl_context)).to be_falsey
        end
      end

      context 'an error is raised inside of #call' do
        before :each do
          ssl_context.expects(:current_cert).raises(StandardError, "BOOM!")
        end

        it 'is false' do
          expect(subject.call(true, ssl_context)).to be_falsey
        end

        it 'makes the error available through #verify_errors' do
          subject.call(true, ssl_context)
          expect(subject.verify_errors).to eq(["BOOM!"])
        end
      end
    end
  end

  describe '#setup_connection' do
    it 'updates the connection for verification' do
      subject.stubs(:ssl_certificates_are_present?).returns(true)
      connection = mock('Net::HTTP')

      connection.expects(:cert_store=).with(ssl_host.ssl_store)
      connection.expects(:ca_file=).with(ca_path)
      connection.expects(:cert=).with(ssl_host.certificate.content)
      connection.expects(:key=).with(ssl_host.key.content)
      connection.expects(:verify_callback=).with(subject)
      connection.expects(:verify_mode=).with(OpenSSL::SSL::VERIFY_PEER)

      subject.setup_connection(connection, ssl_host)
    end

    context 'when no file path is found' do

      it 'does not perform verification if certificate files are missing' do
        subject.stubs(:ssl_certificates_are_present?).returns(false)
        connection = mock('Net::HTTP')

        connection.expects(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)

        subject.setup_connection(connection, ssl_host)
      end
    end
  end

  describe '#valid_peer?' do
    before :each do
      subject.instance_variable_set(:@peer_certs, cert_chain_in_callback_order)
    end

    context 'when the peer presents a valid chain' do
      before :each do
        subject.stubs(:has_authz_peer_cert).returns(true)
      end

      it 'is true' do
        expect(subject.valid_peer?).to be_truthy
      end
    end

    context 'when the peer presents an invalid chain' do
      before :each do
        subject.stubs(:has_authz_peer_cert).returns(false)
      end

      it 'is false' do
        expect(subject.valid_peer?).to be_falsey
      end

      it 'makes a helpful error message available via #verify_errors' do
        subject.valid_peer?
        expect(subject.verify_errors).to eq([expected_authz_error_msg])
      end
    end
  end

  describe '#has_authz_peer_cert' do
    context 'when the Root CA is listed as authorized' do
      it 'returns true when the SSL cert is issued by the Master CA' do
        expect(subject.has_authz_peer_cert(cert_chain, [@pki[:root_cert]])).to be_truthy
      end

      it 'returns true when the SSL cert is issued by the alternate CA' do
        expect(subject.has_authz_peer_cert(cert_chain_alternate, [@pki[:root_cert]])).to be_truthy
      end
    end

    context 'when one intermediate CA is listed as authorized' do
      it 'returns true when the SSL cert is issued by the same intermediate CA' do
        expect(subject.has_authz_peer_cert(cert_chain, [@pki[:int_cert]])).to be_truthy
      end

      it 'returns false when the SSL cert is issued by a different intermediate CA' do
        expect(subject.has_authz_peer_cert(cert_chain_alternate, [@pki[:int_cert]])).to be_falsey
      end
    end
  end

  def cert_chain
    [@pki[:int_node_cert], @pki[:int_cert], @pki[:root_cert]]
  end

  def cert_chain_alternate
    [@pki[:unrevoked_leaf_node_cert], @pki[:leaf_cert], @pki[:revoked_int_cert], @pki[:root_cert]]
  end

  def cert_chain_in_callback_order
    cert_chain.reverse
  end

  let :authz_error_prefix do
    "The server presented a SSL certificate chain which does not include a CA listed in the ssl_client_ca_auth file.  "
  end

  let :expected_authz_error_msg do
    authz_ca_certs = subject.decode_cert_bundle(subject.read_file)
    msg = authz_error_prefix
    msg << "Authorized Issuers: #{authz_ca_certs.collect {|c| c.subject}.join(', ')}  "
    msg << "Peer Chain: #{cert_chain.collect {|c| c.subject}.join(' => ')}"
    msg
  end
end