require 'spec_helper'

require 'puppet/indirector/ssl_file'

describe Puppet::Indirector::SslFile do
  include PuppetSpec::Files

  before :each do
    @indirection = Puppet::Indirector::Indirection.new(nil, :testing)

    module Testing; end
    @file_class = class Testing::MyType < Puppet::Indirector::SslFile
      self
    end

    @setting = :certdir
    @file_class.store_in @setting
    @file_class.store_at nil
    @file_class.store_ca_at nil
    @path = make_absolute("/thisdoesntexist/my_directory")
    Puppet[:noop] = false
    Puppet[@setting] = @path
    Puppet[:trace] = false
  end

  after :each do
    @file_class.store_in nil
    @file_class.store_at nil
    @file_class.store_ca_at nil
    @indirection.delete
  end

  it "should use :main and :ssl upon initialization" do
    expect(Puppet.settings).to receive(:use).with(:main, :ssl)
    @file_class.new
  end

  it "should return a nil collection directory if no directory setting has been provided" do
    @file_class.store_in nil
    expect(@file_class.collection_directory).to be_nil
  end

  it "should return a nil file location if no location has been provided" do
    @file_class.store_at nil
    expect(@file_class.file_location).to be_nil
  end

  it "should fail if no store directory or file location has been set" do
    expect(Puppet.settings).to receive(:use).with(:main, :ssl)
    @file_class.store_in nil
    @file_class.store_at nil
    expect {
      @file_class.new
    }.to raise_error(Puppet::DevError, /No file or directory setting provided/)
  end

  describe "when managing ssl files" do
    before do
      allow(Puppet.settings).to receive(:use)
      @searcher = @file_class.new

      @cert = double('certificate', :name => "myname")
      @certpath = File.join(@path, "myname.pem")

      @request = double('request', :key => @cert.name, :instance => @cert)
    end

    it "should consider the file a ca file if the name is equal to what the SSL::Host class says is the CA name" do
      expect(Puppet::SSL::Host).to receive(:ca_name).and_return("amaca")
      expect(@searcher).to be_ca("amaca")
    end

    describe "when choosing the location for certificates" do
      it "should set them at the ca setting's path if a ca setting is available and the name resolves to the CA name" do
        @file_class.store_in nil
        @file_class.store_at :mysetting
        @file_class.store_ca_at :cakey

        Puppet[:cakey] = File.expand_path("/ca/file")

        expect(@searcher).to receive(:ca?).with(@cert.name).and_return(true)
        expect(@searcher.path(@cert.name)).to eq(Puppet[:cakey])
      end

      it "should set them at the file location if a file setting is available" do
        @file_class.store_in nil
        @file_class.store_at :cacrl

        Puppet[:cacrl] = File.expand_path("/some/file")

        expect(@searcher.path(@cert.name)).to eq(Puppet[:cacrl])
      end

      it "should set them in the setting directory, with the certificate name plus '.pem', if a directory setting is available" do
        expect(@searcher.path(@cert.name)).to eq(@certpath)
      end

      ['../foo', '..\\foo', './../foo', '.\\..\\foo',
       '/foo', '//foo', '\\foo', '\\\\goo',
       "test\0/../bar", "test\0\\..\\bar",
       "..\\/bar", "/tmp/bar", "/tmp\\bar", "tmp\\bar",
       " / bar", " /../ bar", " \\..\\ bar",
       "c:\\foo", "c:/foo", "\\\\?\\UNC\\bar", "\\\\foo\\bar",
       "\\\\?\\c:\\foo", "//?/UNC/bar", "//foo/bar",
       "//?/c:/foo",
      ].each do |input|
        it "should resist directory traversal attacks (#{input.inspect})" do
          expect { @searcher.path(input) }.to raise_error(ArgumentError, /invalid key/)
        end
      end

      # REVISIT: Should probably test MS-DOS reserved names here, too, since
      # they would represent a vulnerability on a Win32 system, should we ever
      # support that path.  Don't forget that 'CON.foo' == 'CON'
      # --daniel 2011-09-24
    end

    describe "when finding certificates on disk" do
      describe "and no certificate is present" do
        it "should return nil" do
          expect(Puppet::FileSystem).to receive(:exist?).with(@path).and_return(true)
          expect(Dir).to receive(:entries).with(@path).and_return([])
          expect(Puppet::FileSystem).to receive(:exist?).with(@certpath).and_return(false)

          expect(@searcher.find(@request)).to be_nil
        end
      end

      describe "and a certificate is present" do
        let(:cert) { double('cert') }
        let(:model) { double('model') }

        before(:each) do
          allow(@file_class).to receive(:model).and_return(model)
        end

        context "is readable" do
          it "should return an instance of the model, which it should use to read the certificate" do
            expect(Puppet::FileSystem).to receive(:exist?).with(@certpath).and_return(true)

            expect(model).to receive(:new).with("myname").and_return(cert)
            expect(cert).to receive(:read).with(@certpath)

            expect(@searcher.find(@request)).to equal(cert)
          end
        end

        context "is unreadable" do
          it "should raise an exception" do
            expect(Puppet::FileSystem).to receive(:exist?).with(@certpath).and_return(true)

            expect(model).to receive(:new).with("myname").and_return(cert)
            expect(cert).to receive(:read).with(@certpath).and_raise(Errno::EACCES)

            expect {
              @searcher.find(@request)
            }.to raise_error(Errno::EACCES)
          end
        end
      end

      describe "and a certificate is present but has uppercase letters" do
        before do
          @request = double('request', :key => "myhost")
        end

        # This is kind of more an integration test; it's for #1382, until
        # the support for upper-case certs can be removed around mid-2009.
        it "should rename the existing file to the lower-case path" do
          @path = @searcher.path("myhost")
          expect(Puppet::FileSystem).to receive(:exist?).with(@path).and_return(false)
          dir, file = File.split(@path)
          expect(Puppet::FileSystem).to receive(:exist?).with(dir).and_return(true)
          expect(Dir).to receive(:entries).with(dir).and_return([".", "..", "something.pem", file.upcase])

          expect(File).to receive(:rename).with(File.join(dir, file.upcase), @path)

          cert = double('cert')
          model = double('model')
          allow(@searcher).to receive(:model).and_return(model)
          expect(@searcher.model).to receive(:new).with("myhost").and_return(cert)
          expect(cert).to receive(:read).with(@path)

          @searcher.find(@request)
        end
      end
    end

    describe "when saving certificates to disk" do
      before do
        allow(FileTest).to receive(:directory?).and_return(true)
        allow(FileTest).to receive(:writable?).and_return(true)
      end

      it "should fail if the directory is absent" do
        expect(FileTest).to receive(:directory?).with(File.dirname(@certpath)).and_return(false)
        expect { @searcher.save(@request) }.to raise_error(Puppet::Error)
      end

      it "should fail if the directory is not writeable" do
        allow(FileTest).to receive(:directory?).and_return(true)
        expect(FileTest).to receive(:writable?).with(File.dirname(@certpath)).and_return(false)
        expect { @searcher.save(@request) }.to raise_error(Puppet::Error)
      end

      it "should save to the path the output of converting the certificate to a string" do
        fh = double('filehandle')
        expect(fh).to receive(:print).with("mycert")

        allow(@searcher).to receive(:write).and_yield(fh)
        expect(@cert).to receive(:to_s).and_return("mycert")

        @searcher.save(@request)
      end

      describe "and a directory setting is set" do
        it "should use the Settings class to write the file" do
          @searcher.class.store_in @setting
          fh = double('filehandle')
          allow(fh).to receive(:print)
          expect(Puppet.settings.setting(@setting)).to receive(:open_file).with(@certpath, 'w:ASCII').and_yield(fh)

          @searcher.save(@request)
        end
      end

      describe "and a file location is set" do
        it "should use the filehandle provided by the Settings" do
          @searcher.class.store_at @setting

          fh = double('filehandle')
          allow(fh).to receive(:print)
          expect(Puppet.settings.setting(@setting)).to receive(:open).with('w:ASCII').and_yield(fh)
          @searcher.save(@request)
        end
      end

      describe "and the name is the CA name and a ca setting is set" do
        it "should use the filehandle provided by the Settings" do
          @searcher.class.store_at @setting
          @searcher.class.store_ca_at :cakey
          Puppet[:cakey] = "castuff stub"

          fh = double('filehandle')
          allow(fh).to receive(:print)
          expect(Puppet.settings.setting(:cakey)).to receive(:open).with('w:ASCII').and_yield(fh)
          allow(@searcher).to receive(:ca?).and_return(true)
          @searcher.save(@request)
        end
      end
    end

    describe "when destroying certificates" do
      describe "that do not exist" do
        before do
          expect(Puppet::FileSystem).to receive(:exist?).with(Puppet::FileSystem.pathname(@certpath)).and_return(false)
        end

        it "should return false" do
          expect(@searcher.destroy(@request)).to be_falsey
        end
      end

      describe "that exist" do
        it "should unlink the certificate file" do
          path = Puppet::FileSystem.pathname(@certpath)
          expect(Puppet::FileSystem).to receive(:exist?).with(path).and_return(true)
          expect(Puppet::FileSystem).to receive(:unlink).with(path)
          @searcher.destroy(@request)
        end

        it "should log that is removing the file" do
          allow(Puppet::FileSystem).to receive(:exist?).and_return(true)
          allow(Puppet::FileSystem).to receive(:unlink)
          expect(Puppet).to receive(:notice)
          @searcher.destroy(@request)
        end
      end
    end

    describe "when searching for certificates" do
      let(:one) { double('one') }
      let(:two) { double('two') }
      let(:one_path) { File.join(@path, 'one.pem') }
      let(:two_path) { File.join(@path, 'two.pem') }
      let(:model) { double('model') }

      before :each do
        allow(@file_class).to receive(:model).and_return(model)
      end

      it "should return a certificate instance for all files that exist" do
        expect(Dir).to receive(:entries).with(@path).and_return(%w{. .. one.pem two.pem})

        expect(model).to receive(:new).with("one").and_return(one)
        expect(one).to receive(:read).with(one_path)
        expect(model).to receive(:new).with("two").and_return(two)
        expect(two).to receive(:read).with(two_path)

        expect(@searcher.search(@request)).to eq([one, two])
      end

      it "should raise an exception if any file is unreadable" do
        expect(Dir).to receive(:entries).with(@path).and_return(%w{. .. one.pem two.pem})

        expect(model).to receive(:new).with("one").and_return(one)
        expect(one).to receive(:read).with(one_path)
        expect(model).to receive(:new).with("two").and_return(two)
        expect(two).to receive(:read).and_raise(Errno::EACCES)

        expect {
          @searcher.search(@request)
        }.to raise_error(Errno::EACCES)
      end

      it "should skip any files that do not match /\.pem$/" do
        expect(Dir).to receive(:entries).with(@path).and_return(%w{. .. one two.notpem})

        expect(model).not_to receive(:new)

        expect(@searcher.search(@request)).to eq([])
      end
    end
  end
end
