# frozen_string_literal: true

require 'tmpdir'
require 'fileutils'

module Puppet::Test
  # This class is intended to provide an API to be used by external projects
  #  when they are running tests that depend on puppet core.  This should
  #  allow us to vary the implementation details of managing puppet's state
  #  for testing, from one version of puppet to the next--without forcing
  #  the external projects to do any of that state management or be aware of
  #  the implementation details.
  #
  # For now, this consists of a few very simple signatures.  The plan is
  #  that it should be the responsibility of the puppetlabs_spec_helper
  #  to broker between external projects and this API; thus, if any
  #  hacks are required (e.g. to determine whether or not a particular)
  #  version of puppet supports this API, those hacks will be consolidated in
  #  one place and won't need to be duplicated in every external project.
  #
  # This should also alleviate the anti-pattern that we've been following,
  #  wherein each external project starts off with a copy of puppet core's
  #  test_helper.rb and is exposed to risk of that code getting out of
  #  sync with core.
  #
  # Since this class will be "library code" that ships with puppet, it does
  #  not use API from any existing test framework such as rspec.  This should
  #  theoretically allow it to be used with other unit test frameworks in the
  #  future, if desired.
  #
  # Note that in the future this API could potentially be expanded to handle
  #  other features such as "around_test", but we didn't see a compelling
  #  reason to deal with that right now.
  class TestHelper
    # Call this method once, as early as possible, such as before loading tests
    # that call Puppet.
    # @return nil
    def self.initialize
      # This meta class instance variable is used as a guard to ensure that
      # before_each, and after_each are only called once. This problem occurs
      # when there are more than one puppet test infrastructure orchestrator in use.
      # The use of both puppetabs-spec_helper, and rodjek-rspec_puppet will cause
      # two resets of the puppet environment, and will cause problem rolling back to
      # a known point as there is no way to differentiate where the calls are coming
      # from. See more information in #before_each_test, and #after_each_test
      # Note that the variable is only initialized to 0 if nil. This is important
      # as more than one orchestrator will call initialize. A second call can not
      # simply set it to 0 since that would potentially destroy an active guard.
      #
      @@reentry_count ||= 0

      @environmentpath = Dir.mktmpdir('environments')
      Dir.mkdir("#{@environmentpath}/production")
      owner = Process.pid
      Puppet.push_context(Puppet.base_context({
                                                :environmentpath => @environmentpath,
                                                :basemodulepath => "",
                                              }), "Initial for specs")
      Puppet::Parser::Functions.reset

      ObjectSpace.define_finalizer(Puppet.lookup(:environments), proc {
        if Process.pid == owner
          FileUtils.rm_rf(@environmentpath)
        end
      })
      Puppet::SSL::Oids.register_puppet_oids
    end

    # Call this method once, when beginning a test run--prior to running
    #  any individual tests.
    # @return nil
    def self.before_all_tests
      # The process environment is a shared, persistent resource.
      $old_env = ENV.to_hash
    end

    # Call this method once, at the end of a test run, when no more tests
    #  will be run.
    # @return nil
    def self.after_all_tests
    end

    # The name of the rollback mark used in the Puppet.context. This is what
    # the test infrastructure returns to for each test.
    #
    ROLLBACK_MARK = "initial testing state"

    # Call this method once per test, prior to execution of each individual test.
    # @return nil
    def self.before_each_test
      # When using both rspec-puppet and puppet-rspec-helper, there are two packages trying
      # to be helpful and orchestrate the callback sequence. We let only the first win, the
      # second callback results in a no-op.
      # Likewise when entering after_each_test(), a check is made to make tear down happen
      # only once.
      #
      return unless @@reentry_count == 0

      @@reentry_count = 1

      Puppet.mark_context(ROLLBACK_MARK)

      # We need to preserve the current state of all our indirection cache and
      # terminus classes.  This is pretty important, because changes to these
      # are global and lead to order dependencies in our testing.
      #
      # We go direct to the implementation because there is no safe, sane public
      # API to manage restoration of these to their default values.  This
      # should, once the value is proved, be moved to a standard API on the
      # indirector.
      #
      # To make things worse, a number of the tests stub parts of the
      # indirector.  These stubs have very specific expectations that what
      # little of the public API we could use is, well, likely to explode
      # randomly in some tests.  So, direct access.  --daniel 2011-08-30
      $saved_indirection_state = {}
      indirections = Puppet::Indirector::Indirection.send(:class_variable_get, :@@indirections)
      indirections.each do |indirector|
        $saved_indirection_state[indirector.name] = {
          :@terminus_class => indirector.instance_variable_get(:@terminus_class).value,
          :@cache_class => indirector.instance_variable_get(:@cache_class).value,
          # dup the termini hash so termini created and registered during
          # the test aren't stored in our saved_indirection_state
          :@termini => indirector.instance_variable_get(:@termini).dup
        }
      end

      # So is the load_path
      $old_load_path = $LOAD_PATH.dup

      initialize_settings_before_each()

      Puppet.push_context(
        {
          trusted_information:
            Puppet::Context::TrustedInformation.new('local', 'testing', {}, { "trusted_testhelper" => true }),
          ssl_context: Puppet::SSL::SSLContext.new(cacerts: []).freeze,
          http_session: proc { Puppet.runtime[:http].create_session }
        },
        "Context for specs"
      )

      # trigger `require 'facter'`
      Puppet.runtime[:facter]

      Puppet::Parser::Functions.reset
      Puppet::Application.clear!
      Puppet::Util::Profiler.clear

      Puppet::Node::Facts.indirection.terminus_class = :memory
      facts = Puppet::Node::Facts.new(Puppet[:node_name_value])
      Puppet::Node::Facts.indirection.save(facts)

      Puppet.clear_deprecation_warnings
    end

    # Call this method once per test, after execution of each individual test.
    # @return nil
    def self.after_each_test
      # Ensure that a matching tear down only happens once per completed setup
      # (see #before_each_test).
      return unless @@reentry_count == 1

      @@reentry_count = 0

      Puppet.settings.send(:clear_everything_for_tests)

      Puppet::Util::Storage.clear
      Puppet::Util::ExecutionStub.reset
      Puppet.runtime.clear

      Puppet.clear_deprecation_warnings

      # uncommenting and manipulating this can be useful when tracking down calls to deprecated code
      # Puppet.log_deprecations_to_file("deprecations.txt", /^Puppet::Util.exec/)

      # Restore the indirector configuration.  See before hook.
      indirections = Puppet::Indirector::Indirection.send(:class_variable_get, :@@indirections)
      indirections.each do |indirector|
        $saved_indirection_state.fetch(indirector.name, {}).each do |variable, value|
          if variable == :@termini
            indirector.instance_variable_set(variable, value)
          else
            indirector.instance_variable_get(variable).value = value
          end
        end
      end
      $saved_indirection_state = nil

      # Restore the global process environment.
      ENV.replace($old_env)

      # Clear all environments
      Puppet.lookup(:environments).clear_all

      # Restore the load_path late, to avoid messing with stubs from the test.
      $LOAD_PATH.clear
      $old_load_path.each { |x| $LOAD_PATH << x }

      Puppet.rollback_context(ROLLBACK_MARK)
    end

    #########################################################################################
    # PRIVATE METHODS (not part of the public TestHelper API--do not call these from outside
    #  of this class!)
    #########################################################################################

    def self.app_defaults_for_tests
      {
        :logdir => "/dev/null",
        :confdir => "/dev/null",
        :publicdir => "/dev/null",
        :codedir => "/dev/null",
        :vardir => "/dev/null",
        :rundir => "/dev/null",
        :hiera_config => "/dev/null",
      }
    end
    private_class_method :app_defaults_for_tests

    def self.initialize_settings_before_each
      Puppet.settings.preferred_run_mode = "user"
      # Initialize "app defaults" settings to a good set of test values
      Puppet.settings.initialize_app_defaults(app_defaults_for_tests)

      # We don't want to depend upon the reported domain name of the
      # machine running the tests, nor upon the DNS setup of that
      # domain.
      Puppet.settings[:use_srv_records] = false

      # Longer keys are secure, but they sure make for some slow testing - both
      # in terms of generating keys, and in terms of anything the next step down
      # the line doing validation or whatever.  Most tests don't care how long
      # or secure it is, just that it exists, so these are better and faster
      # defaults, in testing only.
      #
      # I would make these even shorter, but OpenSSL doesn't support anything
      # below 512 bits.  Sad, really, because a 0 bit key would be just fine.
      Puppet[:keylength] = 512

      # Although we setup a testing context during initialization, some tests
      # will end up creating their own context using the real context objects
      # and use the setting for the environments. In order to avoid those tests
      # having to deal with a missing environmentpath we can just set it right
      # here.
      Puppet[:environmentpath] = @environmentpath
      Puppet[:environment_timeout] = 0
    end
    private_class_method :initialize_settings_before_each
  end
end
