require 'spec_helper'
require 'uri'
require 'puppet/network/http_pool'

describe Puppet::Type.type(:file).attrclass(:source), :uses_checksums => true do
  include PuppetSpec::Files
  include_context 'with supported checksum types'

  around :each do |example|
    Puppet.override(:environments => Puppet::Environments::Static.new) do
      example.run
    end
  end

  let(:filename) { tmpfile('file_source_validate') }
  let(:environment) { Puppet::Node::Environment.remote("myenv") }
  let(:catalog) { Puppet::Resource::Catalog.new(:test, environment) }
  let(:resource) { Puppet::Type.type(:file).new :path => filename, :catalog => catalog }

  before do
    @foobar = make_absolute("/foo/bar baz")
    @feebooz = make_absolute("/fee/booz baz")

    @foobar_uri  = Puppet::Util.uri_unescape(Puppet::Util.path_to_uri(@foobar).to_s)
    @feebooz_uri = Puppet::Util.uri_unescape(Puppet::Util.path_to_uri(@feebooz).to_s)
  end

  it "should be a subclass of Parameter" do
    expect(described_class.superclass).to eq(Puppet::Parameter)
  end

  describe "#validate" do
    it "should fail if the set values are not URLs" do
      expect(URI).to receive(:parse).with('foo').and_raise(RuntimeError)

      expect { resource[:source] = %w{foo} }.to raise_error(Puppet::Error)
    end

    it "should fail if the URI is not a local file, file URI, or puppet URI" do
      expect { resource[:source] = %w{ftp://foo/bar} }.to raise_error(Puppet::Error, /Cannot use URLs of type 'ftp' as source for fileserving/)
    end

    it "should strip trailing forward slashes", :unless => Puppet::Util::Platform.windows? do
      resource[:source] = "/foo/bar\\//"
      expect(resource[:source].first).to match(%r{/foo/bar\\$})
    end

    it "should strip trailing forward and backslashes", :if => Puppet::Util::Platform.windows? do
      resource[:source] = "X:/foo/bar\\//"
      expect(resource[:source].first).to match(/(file\:|file\:\/\/)\/X:\/foo\/bar$/)
    end

    it "should accept an array of sources" do
      resource[:source] = %w{file:///foo/bar puppet://host:8140/foo/bar}
      expect(resource[:source]).to eq(%w{file:///foo/bar puppet://host:8140/foo/bar})
    end

    it "should accept file path characters that are not valid in URI" do
      resource[:source] = 'file:///foo bar'
    end

    it "should reject relative URI sources" do
      expect { resource[:source] = 'foo/bar' }.to raise_error(Puppet::Error)
    end

    it "should reject opaque sources" do
      expect { resource[:source] = 'mailto:foo@com' }.to raise_error(Puppet::Error)
    end

    it "should accept URI authority component" do
      resource[:source] = 'file://host/foo'
      expect(resource[:source]).to eq(%w{file://host/foo})
    end

    it "should accept when URI authority is absent" do
      resource[:source] = 'file:///foo/bar'
      expect(resource[:source]).to eq(%w{file:///foo/bar})
    end
  end

  describe "#munge" do
    it "should prefix file scheme to absolute paths" do
      resource[:source] = filename
      expect(resource[:source]).to eq([Puppet::Util.uri_unescape(Puppet::Util.path_to_uri(filename).to_s)])
    end

    %w[file puppet].each do |scheme|
      it "should not prefix valid #{scheme} URIs" do
        resource[:source] = "#{scheme}:///foo bar"
        expect(resource[:source]).to eq(["#{scheme}:///foo bar"])
      end
    end
  end

  describe "when returning the metadata" do
    before do
      @metadata = double('metadata', :source= => nil)
      allow(resource).to receive(:[]).with(:links).and_return(:manage)
      allow(resource).to receive(:[]).with(:source_permissions).and_return(:use)
      allow(resource).to receive(:[]).with(:checksum).and_return(:checksum)
    end

    it "should return already-available metadata" do
      @source = described_class.new(:resource => resource)
      @source.metadata = "foo"
      expect(@source.metadata).to eq("foo")
    end

    it "should return nil if no @should value is set and no metadata is available" do
      @source = described_class.new(:resource => resource)
      expect(@source.metadata).to be_nil
    end

    it "should collect its metadata using the Metadata class if it is not already set" do
      @source = described_class.new(:resource => resource, :value => @foobar)
      expect(Puppet::FileServing::Metadata.indirection).to receive(:find) do |uri, options|
        expect(uri).to eq(@foobar_uri)
        expect(options[:environment]).to eq(environment)
        expect(options[:links]).to eq(:manage)
        expect(options[:checksum_type]).to eq(:checksum)

        @metadata
      end

      @source.metadata
    end

    it "should use the metadata from the first found source" do
      metadata = double('metadata', :source= => nil)
      @source = described_class.new(:resource => resource, :value => [@foobar, @feebooz])
      options = {
        :environment => environment,
        :links => :manage,
        :source_permissions => :use,
        :checksum_type => :checksum
      }
      expect(Puppet::FileServing::Metadata.indirection).to receive(:find).with(@foobar_uri, options).and_return(nil)
      expect(Puppet::FileServing::Metadata.indirection).to receive(:find).with(@feebooz_uri, options).and_return(metadata)
      expect(@source.metadata).to equal(metadata)
    end

    it "should store the found source as the metadata's source" do
      metadata = double('metadata')
      @source = described_class.new(:resource => resource, :value => @foobar)
      expect(Puppet::FileServing::Metadata.indirection).to receive(:find) do |uri, options|
        expect(uri).to eq(@foobar_uri)
        expect(options[:environment]).to eq(environment)
        expect(options[:links]).to eq(:manage)
        expect(options[:checksum_type]).to eq(:checksum)

        metadata
      end

      expect(metadata).to receive(:source=).with(@foobar_uri)
      @source.metadata
    end

    it "should fail intelligently if an exception is encountered while querying for metadata" do
      @source = described_class.new(:resource => resource, :value => @foobar)
      expect(Puppet::FileServing::Metadata.indirection).to receive(:find) do |uri, options|
        expect(uri).to eq(@foobar_uri)
        expect(options[:environment]).to eq(environment)
        expect(options[:links]).to eq(:manage)
        expect(options[:checksum_type]).to eq(:checksum)
      end.and_raise(RuntimeError)

      expect(@source).to receive(:fail).and_raise(ArgumentError)
      expect { @source.metadata }.to raise_error(ArgumentError)
    end

    it "should fail if no specified sources can be found" do
      @source = described_class.new(:resource => resource, :value => @foobar)
      expect(Puppet::FileServing::Metadata.indirection).to receive(:find)  do |uri, options|
        expect(uri).to eq(@foobar_uri)
        expect(options[:environment]).to eq(environment)
        expect(options[:links]).to eq(:manage)
        expect(options[:checksum_type]).to eq(:checksum)

        nil
      end

      expect(@source).to receive(:fail).and_raise(RuntimeError)

      expect { @source.metadata }.to raise_error(RuntimeError)
    end
  end

  it "should have a method for setting the desired values on the resource" do
    expect(described_class.new(:resource => resource)).to respond_to(:copy_source_values)
  end

  describe "when copying the source values" do
    before do
      allow_any_instance_of(Puppet::Type.type(:file)).to receive(:file).and_return('my/file.pp')
      allow_any_instance_of(Puppet::Type.type(:file)).to receive(:line).and_return(5)
    end

    before :each do
      @resource = Puppet::Type.type(:file).new :path => @foobar

      @source = described_class.new(:resource => @resource)
      @metadata = double('metadata', :owner => 100, :group => 200, :mode => "173", :checksum => "{md5}asdfasdf", :checksum_type => "md5", :ftype => "file", :source => @foobar)
      allow(@source).to receive(:metadata).and_return(@metadata)

      allow(Puppet.features).to receive(:root?).and_return(true)
    end

    it "should not issue an error - except on Windows - if the source mode value is a Numeric" do
      allow(@metadata).to receive(:mode).and_return(0173)
      @resource[:source_permissions] = :use
      if Puppet::Util::Platform.windows?
        expect { @source.copy_source_values }.to raise_error("Should not have tried to use source owner/mode/group on Windows (file: my/file.pp, line: 5)")
      else
        expect { @source.copy_source_values }.not_to raise_error
      end
    end

    it "should not issue an error - except on Windows - if the source mode value is a String" do
      allow(@metadata).to receive(:mode).and_return("173")
      @resource[:source_permissions] = :use
      if Puppet::Util::Platform.windows?
        expect { @source.copy_source_values }.to raise_error("Should not have tried to use source owner/mode/group on Windows (file: my/file.pp, line: 5)")
      else
        expect { @source.copy_source_values }.not_to raise_error
      end
    end

    it "should fail if there is no metadata" do
      allow(@source).to receive(:metadata).and_return(nil)
      expect(@source).to receive(:devfail).and_raise(ArgumentError)
      expect { @source.copy_source_values }.to raise_error(ArgumentError)
    end

    it "should set :ensure to the file type" do
      allow(@metadata).to receive(:ftype).and_return("file")

      @source.copy_source_values
      expect(@resource[:ensure]).to eq(:file)
    end

    it "should not set 'ensure' if it is already set to 'absent'" do
      allow(@metadata).to receive(:ftype).and_return("file")

      @resource[:ensure] = :absent
      @source.copy_source_values
      expect(@resource[:ensure]).to eq(:absent)
    end

    describe "and the source is a file" do
      before do
        allow(@metadata).to receive(:ftype).and_return("file")
        allow(Puppet::Util::Platform).to receive(:windows?).and_return(false)
      end

      context "when source_permissions is `use`" do
        before :each do
          @resource[:source_permissions] = "use"
          @resource[:checksum] = :sha256
        end

        it "should copy the metadata's owner, group, checksum, checksum_type, and mode to the resource if they are not set on the resource" do
          @source.copy_source_values

          expect(@resource[:owner]).to eq(100)
          expect(@resource[:group]).to eq(200)
          expect(@resource[:mode]).to eq("0173")

          # Metadata calls it checksum and checksum_type, we call it content and checksum.
          expect(@resource[:content]).to eq(@metadata.checksum)
          expect(@resource[:checksum]).to eq(@metadata.checksum_type.to_sym)
        end

        it "should not copy the metadata's owner, group, checksum, checksum_type, and mode to the resource if they are already set" do
          @resource[:owner] = 1
          @resource[:group] = 2
          @resource[:mode] = '173'
          @resource[:content] = "foobar"

          @source.copy_source_values

          expect(@resource[:owner]).to eq(1)
          expect(@resource[:group]).to eq(2)
          expect(@resource[:mode]).to eq('0173')
          expect(@resource[:content]).not_to eq(@metadata.checksum)
          expect(@resource[:checksum]).not_to eq(@metadata.checksum_type.to_sym)
        end

        describe "and puppet is not running as root" do
          before do
            allow(Puppet.features).to receive(:root?).and_return(false)
          end

          it "should not try to set the owner" do
            @source.copy_source_values
            expect(@resource[:owner]).to be_nil
          end

          it "should not try to set the group" do
            @source.copy_source_values
            expect(@resource[:group]).to be_nil
          end
        end
      end

      context "when source_permissions is `use_when_creating`" do
        before :each do
          @resource[:source_permissions] = "use_when_creating"
          expect(Puppet.features).to receive(:root?).and_return(true)
          allow(@source).to receive(:local?).and_return(false)
        end

        context "when managing a new file" do
          it "should copy owner and group from local sources" do
            allow(@source).to receive(:local?).and_return(true)

            @source.copy_source_values

            expect(@resource[:owner]).to eq(100)
            expect(@resource[:group]).to eq(200)
            expect(@resource[:mode]).to eq("0173")
          end

          it "copies the remote owner" do
            @source.copy_source_values

            expect(@resource[:owner]).to eq(100)
          end

          it "copies the remote group" do
            @source.copy_source_values

            expect(@resource[:group]).to eq(200)
          end

          it "copies the remote mode" do
            @source.copy_source_values

            expect(@resource[:mode]).to eq("0173")
          end
        end

        context "when managing an existing file" do
          before :each do
            allow(Puppet::FileSystem).to receive(:exist?).with(@resource[:path]).and_return(true)
          end

          it "should not copy owner, group or mode from local sources" do
            allow(@source).to receive(:local?).and_return(true)

            @source.copy_source_values

            expect(@resource[:owner]).to be_nil
            expect(@resource[:group]).to be_nil
            expect(@resource[:mode]).to be_nil
          end

          it "preserves the local owner" do
            @source.copy_source_values

            expect(@resource[:owner]).to be_nil
          end

          it "preserves the local group" do
            @source.copy_source_values

            expect(@resource[:group]).to be_nil
          end

          it "preserves the local mode" do
            @source.copy_source_values

            expect(@resource[:mode]).to be_nil
          end
        end
      end

      context "when source_permissions is default" do
        before :each do
          allow(@source).to receive(:local?).and_return(false)
          expect(Puppet.features).to receive(:root?).and_return(true)
        end

        it "should not copy owner, group or mode from local sources" do
          allow(@source).to receive(:local?).and_return(true)

          @source.copy_source_values

          expect(@resource[:owner]).to be_nil
          expect(@resource[:group]).to be_nil
          expect(@resource[:mode]).to be_nil
        end

        it "preserves the local owner" do
          @source.copy_source_values

          expect(@resource[:owner]).to be_nil
        end

        it "preserves the local group" do
          @source.copy_source_values

          expect(@resource[:group]).to be_nil
        end

        it "preserves the local mode" do
          @source.copy_source_values

          expect(@resource[:mode]).to be_nil
        end
      end
    end

    describe "and the source is a link" do
      before do
        allow(Puppet::Util::Platform).to receive(:windows?).and_return(false)
      end

      it "should set the target to the link destination" do
        allow(@metadata).to receive(:ftype).and_return("link")
        allow(@metadata).to receive(:links).and_return("manage")
        allow(@metadata).to receive(:checksum_type).and_return(nil)
        allow(@resource).to receive(:[])
        allow(@resource).to receive(:[]=)

        expect(@metadata).to receive(:destination).and_return("/path/to/symlink")

        expect(@resource).to receive(:[]=).with(:target, "/path/to/symlink")
        @source.copy_source_values
      end
    end
  end

  it "should have a local? method" do
    expect(described_class.new(:resource => resource)).to be_respond_to(:local?)
  end

  context "when accessing source properties" do
    let(:catalog) { Puppet::Resource::Catalog.new }
    let(:path) { tmpfile('file_resource') }
    let(:resource) { Puppet::Type.type(:file).new(:path => path, :catalog => catalog) }
    let(:sourcepath) { tmpfile('file_source') }

    describe "for local sources" do
      before :each do
        FileUtils.touch(sourcepath)
      end

      describe "on POSIX systems", :if => Puppet.features.posix? do
        ['', "file:", "file://"].each do |prefix|
          it "with prefix '#{prefix}' should be local" do
            resource[:source] = "#{prefix}#{sourcepath}"
            expect(resource.parameter(:source)).to be_local
          end

          it "should be able to return the metadata source full path" do
            resource[:source] = "#{prefix}#{sourcepath}"
            expect(resource.parameter(:source).full_path).to eq(sourcepath)
          end
        end
      end

      describe "on Windows systems", :if => Puppet::Util::Platform.windows? do
        ['', "file:/", "file:///"].each do |prefix|
          it "should be local with prefix '#{prefix}'" do
            resource[:source] = "#{prefix}#{sourcepath}"
            expect(resource.parameter(:source)).to be_local
          end

          it "should be able to return the metadata source full path" do
            resource[:source] = "#{prefix}#{sourcepath}"
            expect(resource.parameter(:source).full_path).to eq(sourcepath)
          end

          it "should convert backslashes to forward slashes" do
            resource[:source] = "#{prefix}#{sourcepath.gsub(/\\/, '/')}"
          end
        end

        it "should be UNC with two slashes"
      end
    end

    %w{puppet http}.each do |scheme|
      describe "for remote (#{scheme}) sources" do
        let(:sourcepath) { "/path/to/source" }
        let(:uri) { URI::Generic.build(:scheme => scheme, :host => 'server', :port => 8192, :path => sourcepath).to_s }

        before(:each) do
          metadata = Puppet::FileServing::Metadata.new(path, :source => uri, 'type' => 'file')
          allow(Puppet::FileServing::Metadata.indirection).to receive(:find).
            with(uri, include(:environment, :links)).and_return(metadata)
          allow(Puppet::FileServing::Metadata.indirection).to receive(:find).
            with(uri, include(:environment, :links)).and_return(metadata)
          resource[:source] = uri
        end

        it "should not be local" do
          expect(resource.parameter(:source)).not_to be_local
        end

        it "should be able to return the metadata source full path" do
          expect(resource.parameter(:source).full_path).to eq("/path/to/source")
        end

        it "should be able to return the source server" do
          expect(resource.parameter(:source).server).to eq("server")
        end

        it "should be able to return the source port" do
          expect(resource.parameter(:source).port).to eq(8192)
        end

        if scheme == 'puppet'
          describe "which don't specify server or port" do
            let(:uri) { "puppet:///path/to/source" }

            it "should return the default source server" do
              Puppet[:server] = "myserver"
              expect(resource.parameter(:source).server).to eq("myserver")
            end

            it "should return the default source port" do
              Puppet[:serverport] = 1234
              expect(resource.parameter(:source).port).to eq(1234)
            end
          end
        end
      end
    end
  end

  describe "when writing" do
    describe "as puppet apply" do
      let(:source_content) { "source file content\r\n"*10 }
      let(:modulepath) { File.join(Puppet[:environmentpath], 'testing', 'modules') }
      let(:env) { Puppet::Node::Environment.create(:testing, [modulepath]) }
      let(:catalog) { Puppet::Resource::Catalog.new(:test, env) }

      before do
        Puppet[:default_file_terminus] = "file_server"
      end

      it "should copy content from the source to the file" do
        resource = Puppet::Type.type(:file).new(path: filename, catalog: catalog, source: file_containing('apply', source_content))
        source = resource.parameter(:source)
        resource.write(source)

        expect(Puppet::FileSystem.binread(filename)).to eq(source_content)
      end

      it 'should use the in-process fileserver if source starts with puppet:///' do
        path = File.join(modulepath, 'mymodule', 'files', 'path')
        Puppet::FileSystem.dir_mkpath(path)
        File.open(path, 'wb') { |f| f.write(source_content) }
        resource = Puppet::Type.type(:file).new(path: filename, catalog: catalog, source: 'puppet:///modules/mymodule/path')

        source = resource.parameter(:source)
        resource.write(source)

        expect(Puppet::FileSystem.binread(filename)).to eq(source_content)
      end

      it 'follows symlinks when retrieving content from the in-process fileserver' do
        # create a 'link' that points to 'target' in the 'mymodule' module
        link = File.join(modulepath, 'mymodule', 'files', 'link')
        target = File.join(modulepath, 'mymodule', 'files', 'target')
        Puppet::FileSystem.dir_mkpath(target)
        File.open(target, 'wb') { |f| f.write(source_content) }
        Puppet::FileSystem.symlink(target, link)
        resource = Puppet::Type.type(:file).new(path: filename, catalog: catalog, source: 'puppet:///modules/mymodule/link')

        source = resource.parameter(:source)
        resource.write(source)

        # 'filename' should be a file containing the contents of the followed link
        expect(Puppet::FileSystem.binread(filename)).to eq(source_content)
      end

      with_digest_algorithms do
        it "should return the checksum computed" do
          resource = Puppet::Type.type(:file).new(path: filename, catalog: catalog, source: file_containing('apply', source_content))

          File.open(filename, 'wb') do |file|
            source = resource.parameter(:source)
            resource[:checksum] = digest_algorithm
            expect(source.write(file)).to eq("{#{digest_algorithm}}#{digest(source_content)}")
          end
        end
      end
    end

    describe "from local source" do
      let(:source_content) { "source file content\r\n"*10 }
      before do
        resource[:backup] = false
        resource[:source] = file_containing('source', source_content)
      end

      it "should copy content from the source to the file" do
        source = resource.parameter(:source)
        resource.write(source)

        expect(Puppet::FileSystem.binread(filename)).to eq(source_content)
      end

      with_digest_algorithms do
        it "should return the checksum computed" do
          File.open(filename, 'wb') do |file|
            source = resource.parameter(:source)
            resource[:checksum] = digest_algorithm
            expect(source.write(file)).to eq("{#{digest_algorithm}}#{digest(source_content)}")
          end
        end
      end
    end

    describe 'from remote source' do
      let(:source_content) { "source file content\n"*10 }
      let(:source) {
        attr = resource.newattr(:source)
        attr.metadata = metadata
        attr
      }
      let(:metadata) {
        Puppet::FileServing::Metadata.new(
          '/modules/:module/foo',
          {
            'type' => 'file',
            'source' => 'puppet:///modules/:module/foo'
          }
        )
      }

      before do
        resource[:backup] = false
      end

      it 'should use an explicit fileserver if source starts with puppet://' do
        metadata.source = "puppet://somehostname:8140/modules/:module/foo"

        stub_request(:get, %r{https://somehostname:8140/puppet/v3/file_content/modules/:module/foo})
          .to_return(status: 200, body: metadata.to_json, headers: { 'Content-Type' => 'application/json' })

        resource.write(source)
      end

      it 'should use the default fileserver if source starts with puppet:///' do
        stub_request(:get, %r{https://#{Puppet[:server]}:8140/puppet/v3/file_content/modules/:module/foo})
          .to_return(status: 200, body: metadata.to_json, headers: { 'Content-Type' => 'application/json' })

        resource.write(source)
      end

      it 'should percent encode reserved characters' do
        metadata.source = 'puppet:///modules/:module/foo bar'

        stub_request(:get, %r{/puppet/v3/file_content/modules/:module/foo%20bar})
          .to_return(status: 200, body: metadata.to_json, headers: { 'Content-Type' => 'application/json' })

        resource.write(source)
      end

      it 'should request binary content' do
        stub_request(:get, %r{/puppet/v3/file_content/modules/:module/foo}) do |request|
          expect(request.headers).to include({'Accept' => 'application/octet-stream'})
        end.to_return(status: 200, body: '', headers: { 'Content-Type' => 'application/octet-stream' })

        resource.write(source)
      end

      it "should request file content from the catalog's environment" do
        Puppet[:environment] = 'doesntexist'

        stub_request(:get, %r{/puppet/v3/file_content})
          .with(query: hash_including("environment" => "myenv"))
          .to_return(status: 200, body: '', headers: { 'Content-Type' => 'application/octet-stream' })

        resource.write(source)
      end

      it 'should request static file content' do
        metadata.content_uri = "puppet://#{Puppet[:server]}:8140/path/to/file"

        stub_request(:get, %r{/puppet/v3/static_file_content/path/to/file})
          .to_return(status: 200, body: '', headers: { 'Content-Type' => 'application/octet-stream' })

        resource.write(source)
      end

      describe 'when handling file_content responses' do
        before do
          File.open(filename, 'w') {|f| f.write "initial file content"}
        end

        it 'should not write anything if source is not found' do
          stub_request(:get, %r{/puppet/v3/file_content/modules/:module/foo}).to_return(status: 404)

          expect { resource.write(source) }.to raise_error(Net::HTTPError, /Error 404 on SERVER:/)
          expect(File.read(filename)).to eq('initial file content')
        end

        it 'should raise an HTTP error in case of server error' do
          stub_request(:get, %r{/puppet/v3/file_content/modules/:module/foo}).to_return(status: 500)

          expect { resource.write(source) }.to raise_error(Net::HTTPError, /Error 500 on SERVER/)
        end

        context 'and the request was successful' do
          before do
            stub_request(:get, %r{/puppet/v3/file_content/modules/:module/foo}).to_return(status: 200, body: source_content)
          end

          it 'should write the contents to the file' do
            resource.write(source)
            expect(Puppet::FileSystem.binread(filename)).to eq(source_content)
          end

          with_digest_algorithms do
            it 'should return the checksum computed' do
              File.open(filename, 'w') do |file|
                resource[:checksum] = digest_algorithm
                expect(source.write(file)).to eq("{#{digest_algorithm}}#{digest(source_content)}")
              end
            end
          end
        end
      end
    end
  end
end
