require 'spec_helper'

require 'puppet/application/device'
require 'puppet/util/network_device/config'
require 'ostruct'
require 'puppet/configurer'
require 'puppet/application/apply'

describe Puppet::Application::Device do
  include PuppetSpec::Files

  before :each do
    @device = Puppet::Application[:device]
    @device.preinit
    allow(Puppet::Util::Log).to receive(:newdestination)

    allow(Puppet::Node.indirection).to receive(:terminus_class=)
    allow(Puppet::Node.indirection).to receive(:cache_class=)
    allow(Puppet::Node::Facts.indirection).to receive(:terminus_class=)
  end

  it "should operate in agent run_mode" do
    expect(@device.class.run_mode.name).to eq(:agent)
  end

  it "should declare a main command" do
    expect(@device).to respond_to(:main)
  end

  it "should declare a preinit block" do
    expect(@device).to respond_to(:preinit)
  end

  describe "in preinit" do
    before :each do
      allow(@device).to receive(:trap)
    end

    it "should catch INT" do
      expect(Signal).to receive(:trap).with(:INT)

      @device.preinit
    end

    it "should init waitforcert to nil" do
      @device.preinit

      expect(@device.options[:waitforcert]).to be_nil
    end

    it "should init target to nil" do
      @device.preinit

      expect(@device.options[:target]).to be_nil
    end
  end

  describe "when handling options" do
    before do
      allow(@device.command_line).to receive(:args).and_return([])
    end

    [:centrallogging, :debug, :verbose,].each do |option|
      it "should declare handle_#{option} method" do
        expect(@device).to respond_to("handle_#{option}".to_sym)
      end

      it "should store argument value when calling handle_#{option}" do
        allow(@device.options).to receive(:[]=).with(option, 'arg')
        @device.send("handle_#{option}".to_sym, 'arg')
      end
    end

    it "should set waitforcert to 0 with --onetime and if --waitforcert wasn't given" do
      Puppet[:onetime] = true
      expect_any_instance_of(Puppet::SSL::Host).to receive(:wait_for_cert).with(0)
      @device.setup_host
    end

    it "should use supplied waitforcert when --onetime is specified" do
      Puppet[:onetime] = true
      @device.handle_waitforcert(60)
      expect_any_instance_of(Puppet::SSL::Host).to receive(:wait_for_cert).with(60)
      @device.setup_host
    end

    it "should use a default value for waitforcert when --onetime and --waitforcert are not specified" do
      expect_any_instance_of(Puppet::SSL::Host).to receive(:wait_for_cert).with(120)
      @device.setup_host
    end

    it "should use the waitforcert setting when checking for a signed certificate" do
      Puppet[:waitforcert] = 10
      expect_any_instance_of(Puppet::SSL::Host).to receive(:wait_for_cert).with(10)
      @device.setup_host
    end

    it "should set the log destination with --logdest" do
      allow(@device.options).to receive(:[]=).with(:setdest, anything)
      expect(Puppet::Log).to receive(:newdestination).with("console")

      @device.handle_logdest("console")
    end

    it "should put the setdest options to true" do
      expect(@device.options).to receive(:[]=).with(:setdest, true)

      @device.handle_logdest("console")
    end

    it "should parse the log destination from the command line" do
      allow(@device.command_line).to receive(:args).and_return(%w{--logdest /my/file})

      expect(Puppet::Util::Log).to receive(:newdestination).with("/my/file")

      @device.parse_options
    end

    it "should store the waitforcert options with --waitforcert" do
      expect(@device.options).to receive(:[]=).with(:waitforcert,42)

      @device.handle_waitforcert("42")
    end

    it "should set args[:Port] with --port" do
      @device.handle_port("42")
      expect(@device.args[:Port]).to eq("42")
    end

    it "should store the target options with --target" do
      expect(@device.options).to receive(:[]=).with(:target,'test123')

      @device.handle_target('test123')
    end

    it "should store the resource options with --resource" do
      expect(@device.options).to receive(:[]=).with(:resource,true)

      @device.handle_resource(true)
    end

    it "should store the facts options with --facts" do
      expect(@device.options).to receive(:[]=).with(:facts,true)

      @device.handle_facts(true)
    end
  end

  describe "during setup" do
    before :each do
      allow(@device.options).to receive(:[])
      Puppet[:libdir] = "/dev/null/lib"
      allow(Puppet::SSL::Host).to receive(:ca_location=)
      allow(Puppet::Transaction::Report.indirection).to receive(:terminus_class=)
      allow(Puppet::Resource::Catalog.indirection).to receive(:terminus_class=)
      allow(Puppet::Resource::Catalog.indirection).to receive(:cache_class=)
      allow(Puppet::Node::Facts.indirection).to receive(:terminus_class=)
      @host = double('host')
      allow(Puppet::SSL::Host).to receive(:new).and_return(@host)
      allow(Puppet).to receive(:settraps)
    end

    it "should call setup_logs" do
      expect(@device).to receive(:setup_logs)
      @device.setup
    end

    describe "when setting up logs" do
      before :each do
        allow(Puppet::Util::Log).to receive(:newdestination)
      end

      it "should set log level to debug if --debug was passed" do
        allow(@device.options).to receive(:[]).with(:debug).and_return(true)
        @device.setup_logs
        expect(Puppet::Util::Log.level).to eq(:debug)
      end

      it "should set log level to info if --verbose was passed" do
        allow(@device.options).to receive(:[]).with(:verbose).and_return(true)
        @device.setup_logs
        expect(Puppet::Util::Log.level).to eq(:info)
      end

      [:verbose, :debug].each do |level|
        it "should set console as the log destination with level #{level}" do
          allow(@device.options).to receive(:[]).with(level).and_return(true)

          expect(Puppet::Util::Log).to receive(:newdestination).with(:console)

          @device.setup_logs
        end
      end

      it "should set a default log destination if no --logdest" do
        allow(@device.options).to receive(:[]).with(:setdest).and_return(false)

        expect(Puppet::Util::Log).to receive(:setup_default)

        @device.setup_logs
      end
    end

    it "should set a central log destination with --centrallogs" do
      allow(@device.options).to receive(:[]).with(:centrallogs).and_return(true)
      Puppet[:server] = "puppet.reductivelabs.com"
      allow(Puppet::Util::Log).to receive(:newdestination).with(:syslog)

      expect(Puppet::Util::Log).to receive(:newdestination).with("puppet.reductivelabs.com")

      @device.setup
    end

    it "should use :main, :agent, :device and :ssl config" do
      expect(Puppet.settings).to receive(:use).with(:main, :agent, :device, :ssl)

      @device.setup
    end

    it "should install a remote ca location" do
      expect(Puppet::SSL::Host).to receive(:ca_location=).with(:remote)

      @device.setup
    end

    it "should tell the report handler to use REST" do
      expect(Puppet::Transaction::Report.indirection).to receive(:terminus_class=).with(:rest)

      @device.setup
    end

    it "should default the catalog_terminus setting to 'rest'" do
      @device.initialize_app_defaults
      expect(Puppet[:catalog_terminus]).to eq(:rest)
    end

    it "should default the node_terminus setting to 'rest'" do
      @device.initialize_app_defaults
      expect(Puppet[:node_terminus]).to eq(:rest)
    end

    it "has an application default :catalog_cache_terminus setting of 'json'" do
      expect(Puppet::Resource::Catalog.indirection).to receive(:cache_class=).with(:json)

      @device.initialize_app_defaults
      @device.setup
    end

    it "should tell the catalog cache class based on the :catalog_cache_terminus setting" do
      Puppet[:catalog_cache_terminus] = "yaml"
      expect(Puppet::Resource::Catalog.indirection).to receive(:cache_class=).with(:yaml)

      @device.initialize_app_defaults
      @device.setup
    end

    it "should not set catalog cache class if :catalog_cache_terminus is explicitly nil" do
      Puppet[:catalog_cache_terminus] = nil
      expect(Puppet::Resource::Catalog.indirection).not_to receive(:cache_class=)

      @device.initialize_app_defaults
      @device.setup
    end

    it "should default the facts_terminus setting to 'network_device'" do
      @device.initialize_app_defaults
      expect(Puppet[:facts_terminus]).to eq(:network_device)
    end
  end

  describe "when initializing each devices SSL" do
    before(:each) do
      @host = double('host')
      allow(@host).to receive(:wait_for_cert)
      allow(Puppet::SSL::Host).to receive(:new).and_return(@host)
    end

    it "should create a new ssl host" do
      expect(Puppet::SSL::Host).to receive(:new).and_return(@host)
      @device.setup_host
    end

    it "should wait for a certificate" do
      allow(@device.options).to receive(:[]).with(:waitforcert).and_return(123)
      expect(@host).to receive(:wait_for_cert).with(123)

      @device.setup_host
    end
  end

  describe "when running" do
    before :each do
      allow(@device.options).to receive(:[]).with(:fingerprint).and_return(false)
      allow(Puppet).to receive(:notice)
      allow(@device.options).to receive(:[]).with(:detailed_exitcodes).and_return(false)
      allow(@device.options).to receive(:[]).with(:target).and_return(nil)
      allow(@device.options).to receive(:[]).with(:apply).and_return(nil)
      allow(@device.options).to receive(:[]).with(:facts).and_return(false)
      allow(@device.options).to receive(:[]).with(:resource).and_return(false)
      allow(@device.options).to receive(:[]).with(:to_yaml).and_return(false)
      allow(@device.options).to receive(:[]).with(:libdir).and_return(nil)
      allow(@device.options).to receive(:[]).with(:client)
      allow(@device.command_line).to receive(:args).and_return([])
      allow(Puppet::Util::NetworkDevice::Config).to receive(:devices).and_return({})
    end

    it "should dispatch to main" do
      allow(@device).to receive(:main)
      @device.run_command
    end

    it "should exit if resource is requested without target" do
      allow(@device.options).to receive(:[]).with(:resource).and_return(true)
      expect { @device.main }.to raise_error(RuntimeError, "resource command requires target")
    end

    it "should exit if facts is requested without target" do
      allow(@device.options).to receive(:[]).with(:facts).and_return(true)
      expect { @device.main }.to raise_error(RuntimeError, "facts command requires target")
    end

    it "should get the device list" do
      device_hash = {}
      expect(Puppet::Util::NetworkDevice::Config).to receive(:devices).and_return(device_hash)
      expect { @device.main }.to exit_with 1
    end

    it "should get a single device, when a valid target parameter is passed" do
      allow(@device.options).to receive(:[]).with(:target).and_return('device1')

      device_hash = {
        "device1" => OpenStruct.new(:name => "device1", :url => "ssh://user:pass@testhost", :provider => "cisco"),
        "device2" => OpenStruct.new(:name => "device2", :url => "https://user:pass@testhost/some/path", :provider => "rest"),
      }

      expect(Puppet::Util::NetworkDevice::Config).to receive(:devices).and_return(device_hash)
      expect(URI).to receive(:parse).with("ssh://user:pass@testhost")
      expect(URI).not_to receive(:parse).with("https://user:pass@testhost/some/path")
      expect { @device.main }.to exit_with 1
    end

    it "should exit, when an invalid target parameter is passed" do
      allow(@device.options).to receive(:[]).with(:target).and_return('bla')
      device_hash = {
        "device1" => OpenStruct.new(:name => "device1", :url => "ssh://user:pass@testhost", :provider => "cisco"),
      }

      expect(Puppet::Util::NetworkDevice::Config).to receive(:devices).and_return(device_hash)
      expect(Puppet).not_to receive(:info).with(/starting applying configuration to/)
      expect { @device.main }.to raise_error(RuntimeError, /Target device \/ certificate 'bla' not found in .*\.conf/)
    end

    it "should error if target is passed and the apply path is incorrect" do
      allow(@device.options).to receive(:[]).with(:apply).and_return('file.pp')
      allow(@device.options).to receive(:[]).with(:target).and_return('device1')

      expect(File).to receive(:file?).and_return(false)
      expect { @device.main }.to raise_error(RuntimeError, /does not exist, cannot apply/)
    end

    it "should run an apply, and not create the state folder" do
      allow(@device.options).to receive(:[]).with(:apply).and_return('file.pp')
      allow(@device.options).to receive(:[]).with(:target).and_return('device1')
      device_hash = {
        "device1" => OpenStruct.new(:name => "device1", :url => "ssh://user:pass@testhost", :provider => "cisco"),
      }
      expect(Puppet::Util::NetworkDevice::Config).to receive(:devices).and_return(device_hash)
      allow(Puppet::Util::NetworkDevice).to receive(:init)
      expect(File).to receive(:file?).and_return(true)

      allow(::File).to receive(:directory?).and_return(false)
      state_path = tmpfile('state')
      Puppet[:statedir] = state_path
      expect(File).to receive(:directory?).with(state_path).and_return(true)
      expect(FileUtils).not_to receive(:mkdir_p).with(state_path)

      expect(Puppet::Util::CommandLine).to receive(:new).once
      expect(Puppet::Application::Apply).to receive(:new).once

      expect(Puppet::Configurer).not_to receive(:new)
      expect { @device.main }.to exit_with 1
    end

    it "should run an apply, and create the state folder" do
      allow(@device.options).to receive(:[]).with(:apply).and_return('file.pp')
      allow(@device.options).to receive(:[]).with(:target).and_return('device1')
      device_hash = {
        "device1" => OpenStruct.new(:name => "device1", :url => "ssh://user:pass@testhost", :provider => "cisco"),
      }
      expect(Puppet::Util::NetworkDevice::Config).to receive(:devices).and_return(device_hash)
      allow(Puppet::Util::NetworkDevice).to receive(:init)
      expect(File).to receive(:file?).and_return(true)
      expect(FileUtils).to receive(:mkdir_p).once

      expect(Puppet::Util::CommandLine).to receive(:new).once
      expect(Puppet::Application::Apply).to receive(:new).once

      expect(Puppet::Configurer).not_to receive(:new)
      expect { @device.main }.to exit_with 1
    end

    it "should exit if the device list is empty" do
      expect { @device.main }.to exit_with 1
    end

    describe "for each device" do
      before(:each) do
        Puppet[:vardir] = make_absolute("/dummy")
        Puppet[:confdir] = make_absolute("/dummy")
        Puppet[:certname] = "certname"
        @device_hash = {
          "device1" => OpenStruct.new(:name => "device1", :url => "ssh://user:pass@testhost", :provider => "cisco"),
          "device2" => OpenStruct.new(:name => "device2", :url => "https://user:pass@testhost/some/path", :provider => "rest"),
        }
        allow(Puppet::Util::NetworkDevice::Config).to receive(:devices).and_return(@device_hash)
        allow(Puppet).to receive(:[]=)
        allow(Puppet.settings).to receive(:use)
        allow(@device).to receive(:setup_host)
        allow(Puppet::Util::NetworkDevice).to receive(:init)
        @configurer = double('configurer')
        allow(@configurer).to receive(:run)
        allow(Puppet::Configurer).to receive(:new).and_return(@configurer)
      end

      it "should set vardir to the device vardir" do
        expect(Puppet).to receive(:[]=).with(:vardir, make_absolute("/dummy/devices/device1"))
        expect { @device.main }.to exit_with 1
      end

      it "should set confdir to the device confdir" do
        expect(Puppet).to receive(:[]=).with(:confdir, make_absolute("/dummy/devices/device1"))
        expect { @device.main }.to exit_with 1
      end

      it "should set certname to the device certname" do
        expect(Puppet).to receive(:[]=).with(:certname, "device1")
        expect(Puppet).to receive(:[]=).with(:certname, "device2")
        expect { @device.main }.to exit_with 1
      end

      it "should raise an error if no type is given" do
        allow(@device.options).to receive(:[]).with(:resource).and_return(true)
        allow(@device.options).to receive(:[]).with(:target).and_return('device1')
        allow(@device.command_line).to receive(:args).and_return([])
        expect(Puppet).to receive(:log_exception) { |e| expect(e.message).to eq("You must specify the type to display") }
        expect { @device.main }.to exit_with 1
      end

      it "should raise an error if the type is not found" do
        allow(@device.options).to receive(:[]).with(:resource).and_return(true)
        allow(@device.options).to receive(:[]).with(:target).and_return('device1')
        allow(@device.command_line).to receive(:args).and_return(['nope'])
        expect(Puppet).to receive(:log_exception) { |e| expect(e.message).to eq("Could not find type nope") }
        expect { @device.main }.to exit_with 1
      end

      it "should retrieve all resources of a type" do
        allow(@device.options).to receive(:[]).with(:resource).and_return(true)
        allow(@device.options).to receive(:[]).with(:target).and_return('device1')
        allow(@device.command_line).to receive(:args).and_return(['user'])
        expect(Puppet::Resource.indirection).to receive(:search).with('user/', {}).and_return([])
        expect { @device.main }.to exit_with 0
      end

      it "should retrieve named resources of a type" do
        resource = Puppet::Type.type(:user).new(:name => "jim").to_resource
        allow(@device.options).to receive(:[]).with(:resource).and_return(true)
        allow(@device.options).to receive(:[]).with(:target).and_return('device1')
        allow(@device.command_line).to receive(:args).and_return(['user', 'jim'])
        expect(Puppet::Resource.indirection).to receive(:find).with('user/jim').and_return(resource)
        expect(@device).to receive(:puts).with("user { 'jim':\n}")
        expect { @device.main }.to exit_with 0
      end

      it "should output resources as YAML" do
        resources = [
          Puppet::Type.type(:user).new(:name => "title").to_resource,
        ]
        allow(@device.options).to receive(:[]).with(:resource).and_return(true)
        allow(@device.options).to receive(:[]).with(:target).and_return('device1')
        allow(@device.options).to receive(:[]).with(:to_yaml).and_return(true)
        allow(@device.command_line).to receive(:args).and_return(['user'])
        expect(Puppet::Resource.indirection).to receive(:search).with('user/', {}).and_return(resources)
        expect(@device).to receive(:puts).with("---\nuser:\n  title: {}\n")
        expect { @device.main }.to exit_with 0
      end

      it "should retrieve facts" do
        indirection_fact_values = {"operatingsystem"=>"cisco_ios","clientcert"=>"3750"}
        indirection_facts = Puppet::Node::Facts.new("nil", indirection_fact_values)
        allow(@device.options).to receive(:[]).with(:facts).and_return(true)
        allow(@device.options).to receive(:[]).with(:target).and_return('device1')
        expect(Puppet::Node::Facts.indirection).to receive(:find).with(nil, anything()).and_return(indirection_facts)
        expect(@device).to receive(:puts).with(/name.*3750.*\n.*values.*\n.*operatingsystem.*cisco_ios/)
        expect { @device.main }.to exit_with 0
      end

      it "should make sure all the required folders and files are created" do
        expect(Puppet.settings).to receive(:use).with(:main, :agent, :ssl).twice
        expect { @device.main }.to exit_with 1
      end

      it "should initialize the device singleton" do
        expect(Puppet::Util::NetworkDevice).to receive(:init).with(@device_hash["device1"]).ordered
        expect(Puppet::Util::NetworkDevice).to receive(:init).with(@device_hash["device2"]).ordered
        expect { @device.main }.to exit_with 1
      end

      it "should retrieve plugins and print the device url scheme, host, and port" do
        allow(Puppet).to receive(:info)
        expect(Puppet).to receive(:info).with("Retrieving pluginfacts")
        expect(Puppet).to receive(:info).with("starting applying configuration to device1 at ssh://testhost")
        expect(Puppet).to receive(:info).with("starting applying configuration to device2 at https://testhost:443/some/path")
        expect { @device.main }.to exit_with 1
      end

      it "should setup the SSL context" do
        expect(@device).to receive(:setup_host).twice
        expect { @device.main }.to exit_with 1
      end

      it "should launch a configurer for this device" do
        expect(@configurer).to receive(:run).twice
        expect { @device.main }.to exit_with 1
      end

      it "exits 1 when configurer raises error" do
        expect(@configurer).to receive(:run).and_raise(Puppet::Error).ordered
        expect(@configurer).to receive(:run).and_return(0).ordered
        expect { @device.main }.to exit_with 1
      end

      it "exits 0 when run happens without puppet errors but with failed run" do
        allow(@configurer).to receive(:run).and_return(6, 2)
        expect { @device.main }.to exit_with 0
      end

      it "should make the Puppet::Pops::Loaaders available" do
        expect(@configurer).to receive(:run).with(:network_device => true, :pluginsync => true) do
          fail('Loaders not available') unless Puppet.lookup(:loaders) { nil }.is_a?(Puppet::Pops::Loaders)
          true
        end.and_return(6, 2)
        expect { @device.main }.to exit_with 0
      end

      it "exits 2 when --detailed-exitcodes and successful runs" do
        allow(@device.options).to receive(:[]).with(:detailed_exitcodes).and_return(true)
        allow(@configurer).to receive(:run).and_return(0, 2)
        expect { @device.main }.to exit_with 2
      end

      it "exits 1 when --detailed-exitcodes and failed parse" do
        @configurer = double('configurer')
        allow(Puppet::Configurer).to receive(:new).and_return(@configurer)
        allow(@device.options).to receive(:[]).with(:detailed_exitcodes).and_return(true)
        allow(@configurer).to receive(:run).and_return(6, 1)
        expect { @device.main }.to exit_with 7
      end

      it "exits 6 when --detailed-exitcodes and failed run" do
        @configurer = double('configurer')
        allow(Puppet::Configurer).to receive(:new).and_return(@configurer)
        allow(@device.options).to receive(:[]).with(:detailed_exitcodes).and_return(true)
        allow(@configurer).to receive(:run).and_return(6, 2)
        expect { @device.main }.to exit_with 6
      end

      [:vardir, :confdir].each do |setting|
        it "should cleanup the #{setting} setting after the run" do
          all_devices = Set.new(@device_hash.keys.map do |device_name| make_absolute("/dummy/devices/#{device_name}") end)
          found_devices = Set.new()

          # a block to use in a few places later to validate the updated settings
          p = Proc.new do |my_setting, my_value|
            expect(all_devices).to include(my_value)
            found_devices.add(my_value)
          end

          all_devices.size.times do
            ## one occurrence of set / run / set("/dummy") for each device
            expect(Puppet).to receive(:[]=, &p).with(setting, anything).ordered
            expect(@configurer).to receive(:run).ordered
            expect(Puppet).to receive(:[]=).with(setting, make_absolute("/dummy")).ordered
          end

          expect { @device.main }.to exit_with 1

          expect(found_devices).to eq(all_devices)
        end
      end

      it "should cleanup the certname setting after the run" do
        all_devices = Set.new(@device_hash.keys)
        found_devices = Set.new()

        # a block to use in a few places later to validate the updated settings
        p = Proc.new do |my_setting, my_value|
          expect(all_devices).to include(my_value)
          found_devices.add(my_value)
        end

        allow(Puppet).to receive(:[]=)
        all_devices.size.times do
          ## one occurrence of set / run / set("certname") for each device
          expect(Puppet).to receive(:[]=, &p).with(:certname, anything).ordered
          expect(@configurer).to receive(:run).ordered
          expect(Puppet).to receive(:[]=).with(:certname, "certname").ordered
        end


        expect { @device.main }.to exit_with 1

        # make sure that we were called with each of the defined devices
        expect(found_devices).to eq(all_devices)
      end

      it "should expire all cached attributes" do
        expect(Puppet::SSL::Host).to receive(:reset).twice

        expect { @device.main }.to exit_with 1
      end
    end
  end
end
