#! /usr/bin/env ruby
require 'spec_helper'

require 'puppet/transaction/resource_harness'

describe Puppet::Transaction::ResourceHarness do
  include PuppetSpec::Files

  before do
    @mode_750 = Puppet.features.microsoft_windows? ? '644' : '750'
    @mode_755 = Puppet.features.microsoft_windows? ? '644' : '755'
    path = make_absolute("/my/file")

    @transaction = Puppet::Transaction.new(Puppet::Resource::Catalog.new, nil, nil)
    @resource = Puppet::Type.type(:file).new :path => path
    @harness = Puppet::Transaction::ResourceHarness.new(@transaction)
    @current_state = Puppet::Resource.new(:file, path)
    @resource.stubs(:retrieve).returns @current_state
  end

  it "should accept a transaction at initialization" do
    harness = Puppet::Transaction::ResourceHarness.new(@transaction)
    harness.transaction.should equal(@transaction)
  end

  it "should delegate to the transaction for its relationship graph" do
    @transaction.expects(:relationship_graph).returns "relgraph"
    Puppet::Transaction::ResourceHarness.new(@transaction).relationship_graph.should == "relgraph"
  end

  describe "when evaluating a resource" do
    it "produces a resource state that describes what happened with the resource" do
      status = @harness.evaluate(@resource)

      status.resource.should == @resource.ref
      status.should_not be_failed
      status.events.should be_empty
    end

    it "retrieves the current state of the resource" do
      @resource.expects(:retrieve).returns @current_state

      @harness.evaluate(@resource)
    end

    it "produces a failure status for the resource when an error occurs" do
      the_message = "retrieve failed in testing"
      @resource.expects(:retrieve).raises(ArgumentError.new(the_message))

      status = @harness.evaluate(@resource)

      status.should be_failed
      events_to_hash(status.events).collect do |event|
        { :@status => event[:@status], :@message => event[:@message] }
      end.should == [{ :@status => "failure", :@message => the_message }]
    end

    it "records the time it took to evaluate the resource" do
      before = Time.now
      status = @harness.evaluate(@resource)
      after = Time.now

      status.evaluation_time.should be <= after - before
    end
  end

  def events_to_hash(events)
    events.map do |event|
      hash = {}
      event.instance_variables.each do |varname|
        hash[varname.to_sym] = event.instance_variable_get(varname)
      end
      hash
    end
  end

  def make_stub_provider
    stubProvider = Class.new(Puppet::Type)
    stubProvider.instance_eval do
      initvars

      newparam(:name) do
        desc "The name var"
        isnamevar
      end

      newproperty(:foo) do
        desc "A property that can be changed successfully"
        def sync
        end

        def retrieve
          :absent
        end

        def insync?(reference_value)
          false
        end
      end

      newproperty(:bar) do
        desc "A property that raises an exception when you try to change it"
        def sync
          raise ZeroDivisionError.new('bar')
        end

        def retrieve
          :absent
        end

        def insync?(reference_value)
          false
        end
      end

      newproperty(:baz) do
        desc "A property that raises an Exception (not StandardError) when you try to change it"
        def sync
          raise Exception.new('baz')
        end

        def retrieve
          :absent
        end

        def insync?(reference_value)
          false
        end
      end

      newproperty(:brillig) do
        desc "A property that raises a StandardError exception when you test if it's insync?"
        def sync
        end

        def retrieve
          :absent
        end

        def insync?(reference_value)
          raise ZeroDivisionError.new('brillig')
        end
      end

      newproperty(:slithy) do
        desc "A property that raises an Exception when you test if it's insync?"
        def sync
        end

        def retrieve
          :absent
        end

        def insync?(reference_value)
          raise Exception.new('slithy')
        end
      end
    end
    stubProvider
  end


  context "interaction of ensure with other properties" do
    def an_ensurable_resource_reacting_as(behaviors)
      stub_type = Class.new(Puppet::Type)
      stub_type.class_eval do
        initvars
        ensurable do
          def sync
            (@resource.behaviors[:on_ensure] || proc {}).call
          end

          def insync?(value)
            @resource.behaviors[:ensure_insync?]
          end
        end

        newparam(:name) do
          desc "The name var"
          isnamevar
        end

        newproperty(:prop) do
          newvalue("new") do
            #noop
          end

          def retrieve
            "old"
          end
        end

        attr_reader :behaviors

        def initialize(options)
          @behaviors = options.delete(:behaviors)
          super
        end

        def exists?
          @behaviors[:present?]
        end

        def present?(resource)
          @behaviors[:present?]
        end

        def self.name
          "Testing"
        end
      end
      stub_type.new(:behaviors => behaviors,
                    :ensure => :present,
                    :name => "testing",
                    :prop => "new")
    end

    it "ensure errors means that the rest doesn't happen" do
      resource = an_ensurable_resource_reacting_as(:ensure_insync? => false, :on_ensure => proc { raise StandardError }, :present? => true)

      status = @harness.evaluate(resource)

      expect(status.events.length).to eq(1)
      expect(status.events[0].property).to eq('ensure')
      expect(status.events[0].name.to_s).to eq('Testing_created')
      expect(status.events[0].status).to eq('failure')
    end

    it "ensure fails completely means that the rest doesn't happen" do
      resource = an_ensurable_resource_reacting_as(:ensure_insync? => false, :on_ensure => proc { raise Exception }, :present? => false)

      expect do
        @harness.evaluate(resource)
      end.to raise_error(Exception)

      @logs.first.message.should == "change from absent to present failed: Exception"
      @logs.first.level.should == :err
    end

    it "ensure succeeds means that the rest doesn't happen" do
      resource = an_ensurable_resource_reacting_as(:ensure_insync? => false, :on_ensure => proc { }, :present? => true)

      status = @harness.evaluate(resource)

      expect(status.events.length).to eq(1)
      expect(status.events[0].property).to eq('ensure')
      expect(status.events[0].name.to_s).to eq('Testing_created')
      expect(status.events[0].status).to eq('success')
    end

    it "ensure is in sync means that the rest *does* happen" do
      resource = an_ensurable_resource_reacting_as(:ensure_insync? => true, :present? => true)

      status = @harness.evaluate(resource)

      expect(status.events.length).to eq(1)
      expect(status.events[0].property).to eq('prop')
      expect(status.events[0].name.to_s).to eq('prop_changed')
      expect(status.events[0].status).to eq('success')
    end

    it "ensure is in sync but resource not present, means that the rest doesn't happen" do
      resource = an_ensurable_resource_reacting_as(:ensure_insync? => true, :present? => false)

      status = @harness.evaluate(resource)

      expect(status.events).to be_empty
    end
  end

  describe "when a caught error occurs" do
    before :each do
      stub_provider = make_stub_provider
      resource = stub_provider.new :name => 'name', :foo => 1, :bar => 2
      resource.expects(:err).never
      @status = @harness.evaluate(resource)
    end

    it "should record previous successful events" do
      @status.events[0].property.should == 'foo'
      @status.events[0].status.should == 'success'
    end

    it "should record a failure event" do
      @status.events[1].property.should == 'bar'
      @status.events[1].status.should == 'failure'
    end
  end

  describe "when an Exception occurs during sync" do
    before :each do
      stub_provider = make_stub_provider
      @resource = stub_provider.new :name => 'name', :baz => 1
      @resource.expects(:err).never
    end

    it "should log and pass the exception through" do
      lambda { @harness.evaluate(@resource) }.should raise_error(Exception, /baz/)
      @logs.first.message.should == "change from absent to 1 failed: baz"
      @logs.first.level.should == :err
    end
  end

  describe "when a StandardError exception occurs during insync?" do
    before :each do
      stub_provider = make_stub_provider
      @resource = stub_provider.new :name => 'name', :brillig => 1
      @resource.expects(:err).never
    end

    it "should record a failure event" do
      @status = @harness.evaluate(@resource)
      @status.events[0].name.to_s.should == 'brillig_changed'
      @status.events[0].property.should == 'brillig'
      @status.events[0].status.should == 'failure'
    end
  end

  describe "when an Exception occurs during insync?" do
    before :each do
      stub_provider = make_stub_provider
      @resource = stub_provider.new :name => 'name', :slithy => 1
      @resource.expects(:err).never
    end

    it "should log and pass the exception through" do
      lambda { @harness.evaluate(@resource) }.should raise_error(Exception, /slithy/)
      @logs.first.message.should == "change from absent to 1 failed: slithy"
      @logs.first.level.should == :err
    end
  end

  describe "when auditing" do
    it "should not call insync? on parameters that are merely audited" do
      stub_provider = make_stub_provider
      resource = stub_provider.new :name => 'name', :audit => ['foo']
      resource.property(:foo).expects(:insync?).never
      status = @harness.evaluate(resource)

      expect(status.events).to be_empty
    end

    it "should be able to audit a file's group" do # see bug #5710
      test_file = tmpfile('foo')
      File.open(test_file, 'w').close
      resource = Puppet::Type.type(:file).new :path => test_file, :audit => ['group'], :backup => false
      resource.expects(:err).never # make sure no exceptions get swallowed

      status = @harness.evaluate(resource)

      status.events.each do |event|
        event.status.should != 'failure'
      end
    end

    it "should not ignore microseconds when auditing a file's mtime" do
      test_file = tmpfile('foo')
      File.open(test_file, 'w').close
      resource = Puppet::Type.type(:file).new :path => test_file, :audit => ['mtime'], :backup => false

      # construct a property hash with nanosecond resolution as would be
      # found on an ext4 file system
      time_with_nsec_resolution = Time.at(1000, 123456.999)
      current_from_filesystem    = {:mtime => time_with_nsec_resolution}

      # construct a property hash with a 1 microsecond difference from above
      time_with_usec_resolution = Time.at(1000, 123457.000)
      historical_from_state_yaml = {:mtime => time_with_usec_resolution}

      # set up the sequence of stubs; yeah, this is pretty
      # brittle, so this might need to be adjusted if the
      # resource_harness logic changes
      resource.expects(:retrieve).returns(current_from_filesystem)
      Puppet::Util::Storage.stubs(:cache).with(resource).
        returns(historical_from_state_yaml).then.
        returns(current_from_filesystem).then.
        returns(current_from_filesystem)

      # there should be an audit change recorded, since the two
      # timestamps differ by at least 1 microsecond
      status = @harness.evaluate(resource)
      status.events.should_not be_empty
      status.events.each do |event|
        event.message.should =~ /audit change: previously recorded/
      end
    end

    it "should ignore nanoseconds when auditing a file's mtime" do
      test_file = tmpfile('foo')
      File.open(test_file, 'w').close
      resource = Puppet::Type.type(:file).new :path => test_file, :audit => ['mtime'], :backup => false

      # construct a property hash with nanosecond resolution as would be
      # found on an ext4 file system
      time_with_nsec_resolution = Time.at(1000, 123456.789)
      current_from_filesystem    = {:mtime => time_with_nsec_resolution}

      # construct a property hash with the same timestamp as above,
      # truncated to microseconds, as would be read back from state.yaml
      time_with_usec_resolution = Time.at(1000, 123456.000)
      historical_from_state_yaml = {:mtime => time_with_usec_resolution}

      # set up the sequence of stubs; yeah, this is pretty
      # brittle, so this might need to be adjusted if the
      # resource_harness logic changes
      resource.expects(:retrieve).returns(current_from_filesystem)
      Puppet::Util::Storage.stubs(:cache).with(resource).
        returns(historical_from_state_yaml).then.
        returns(current_from_filesystem).then.
        returns(current_from_filesystem)

      # there should be no audit change recorded, despite the
      # slight difference in the two timestamps
      status = @harness.evaluate(resource)
      status.events.each do |event|
        event.message.should_not =~ /audit change: previously recorded/
      end
    end
  end

  describe "when applying changes" do
    it "should not apply changes if allow_changes?() returns false" do
      test_file = tmpfile('foo')
      resource = Puppet::Type.type(:file).new :path => test_file, :backup => false, :ensure => :file
      resource.expects(:err).never # make sure no exceptions get swallowed
      @harness.expects(:allow_changes?).with(resource).returns false
      status = @harness.evaluate(resource)
      Puppet::FileSystem.exist?(test_file).should == false
    end
  end

  describe "when determining whether the resource can be changed" do
    before do
      @resource.stubs(:purging?).returns true
      @resource.stubs(:deleting?).returns true
    end

    it "should be true if the resource is not being purged" do
      @resource.expects(:purging?).returns false
      @harness.should be_allow_changes(@resource)
    end

    it "should be true if the resource is not being deleted" do
      @resource.expects(:deleting?).returns false
      @harness.should be_allow_changes(@resource)
    end

    it "should be true if the resource has no dependents" do
      @harness.relationship_graph.expects(:dependents).with(@resource).returns []
      @harness.should be_allow_changes(@resource)
    end

    it "should be true if all dependents are being deleted" do
      dep = stub 'dependent', :deleting? => true
      @harness.relationship_graph.expects(:dependents).with(@resource).returns [dep]
      @resource.expects(:purging?).returns true
      @harness.should be_allow_changes(@resource)
    end

    it "should be false if the resource's dependents are not being deleted" do
      dep = stub 'dependent', :deleting? => false, :ref => "myres"
      @resource.expects(:warning)
      @harness.relationship_graph.expects(:dependents).with(@resource).returns [dep]
      @harness.should_not be_allow_changes(@resource)
    end
  end

  describe "when finding the schedule" do
    before do
      @catalog = Puppet::Resource::Catalog.new
      @resource.catalog = @catalog
    end

    it "should warn and return nil if the resource has no catalog" do
      @resource.catalog = nil
      @resource.expects(:warning)

      @harness.schedule(@resource).should be_nil
    end

    it "should return nil if the resource specifies no schedule" do
      @harness.schedule(@resource).should be_nil
    end

    it "should fail if the named schedule cannot be found" do
      @resource[:schedule] = "whatever"
      @resource.expects(:fail)
      @harness.schedule(@resource)
    end

    it "should return the named schedule if it exists" do
      sched = Puppet::Type.type(:schedule).new(:name => "sched")
      @catalog.add_resource(sched)
      @resource[:schedule] = "sched"
      @harness.schedule(@resource).to_s.should == sched.to_s
    end
  end

  describe "when determining if a resource is scheduled" do
    before do
      @catalog = Puppet::Resource::Catalog.new
      @resource.catalog = @catalog
    end

    it "should return true if 'ignoreschedules' is set" do
      Puppet[:ignoreschedules] = true
      @resource[:schedule] = "meh"
      @harness.should be_scheduled(@resource)
    end

    it "should return true if the resource has no schedule set" do
      @harness.should be_scheduled(@resource)
    end

    it "should return the result of matching the schedule with the cached 'checked' time if a schedule is set" do
      t = Time.now
      @harness.expects(:cached).with(@resource, :checked).returns(t)

      sched = Puppet::Type.type(:schedule).new(:name => "sched")
      @catalog.add_resource(sched)
      @resource[:schedule] = "sched"

      sched.expects(:match?).with(t.to_i).returns "feh"

      @harness.scheduled?(@resource).should == "feh"
    end
  end

  it "should be able to cache data in the Storage module" do
    data = {}
    Puppet::Util::Storage.expects(:cache).with(@resource).returns data
    @harness.cache(@resource, :foo, "something")

    data[:foo].should == "something"
  end

  it "should be able to retrieve data from the cache" do
    data = {:foo => "other"}
    Puppet::Util::Storage.expects(:cache).with(@resource).returns data
    @harness.cached(@resource, :foo).should == "other"
  end
end
