require "spec_helper"
require "chef/cookbook/synchronizer"
require "chef/cookbook_version"
require "chef-utils/dist"

describe Chef::CookbookCacheCleaner do
  describe "when cleaning up unused cookbook components" do

    let(:cleaner) do
      cleaner = Chef::CookbookCacheCleaner.instance
      cleaner.reset!
      cleaner
    end

    let(:file_cache) { double("Chef::FileCache with files from unused cookbooks") }

    let(:unused_template_files) do
      %w{
        cookbooks/unused/templates/default/foo.conf.erb
        cookbooks/unused/tempaltes/default/bar.conf.erb
      }
    end

    let(:valid_cached_cb_files) do
      %w{
        cookbooks/valid1/recipes/default.rb
        cookbooks/valid2/recipes/default.rb
      }
    end

    before do
      valid_cached_cb_files.each do |cbf|
        cleaner.mark_file_as_valid(cbf)
      end
    end

    it "removes all files not validated during the chef run" do
      expect(file_cache).to receive(:find).with(File.join(%w{cookbooks ** {*,.*}})).and_return(valid_cached_cb_files + unused_template_files)
      unused_template_files.each do |cbf|
        expect(file_cache).to receive(:delete).with(cbf)
      end
      allow(cleaner).to receive(:cache).and_return(file_cache)
      cleaner.cleanup_file_cache
    end

    it "does not remove anything when skip_removal is true" do
      cleaner.skip_removal = true
      allow(cleaner.cache).to receive(:find).and_return(%w{cookbooks/valid1/recipes/default.rb cookbooks/valid2/recipes/default.rb})
      expect(cleaner.cache).not_to receive(:delete)
      cleaner.cleanup_file_cache
    end

    it "does not remove anything on #{ChefUtils::Dist::Solo::EXEC}" do
      Chef::Config[:solo_legacy_mode] = true
      allow(cleaner.cache).to receive(:find).and_return(%w{cookbooks/valid1/recipes/default.rb cookbooks/valid2/recipes/default.rb})
      expect(cleaner.cache).not_to receive(:delete)
      cleaner.cleanup_file_cache
    end
  end
end

describe Chef::CookbookSynchronizer do
  let(:cookbook_a_default_recipe) do
    {
      "path" => "recipes/default.rb",
      "name" => "recipes/default.rb",
      "url" => "http://chef.example.com/abc123",
      "checksum" => "abc123",
    }
  end

  let(:cookbook_a_default_attrs) do
    {
      "path" => "attributes/default.rb",
      "name" => "attributes/default.rb",
      "url" => "http://chef.example.com/abc456",
      "checksum" => "abc456",
    }
  end

  let(:cookbook_a_template) do
    {
      "path" => "templates/default/apache2.conf.erb",
      "name" => "templates/apache2.conf.erb",
      "url" => "http://chef.example.com/ffffff",
      "checksum" => "abc125",
    }
  end

  let(:cookbook_a_file) do
    {
      "path" => "files/default/megaman.conf",
      "name" => "files/megaman.conf",
      "url" => "http://chef.example.com/megaman.conf",
      "checksum" => "abc124",
    }
  end

  let(:cookbook_a_manifest) do
    cookbook_a_manifest = { all_files: [ cookbook_a_default_recipe, cookbook_a_default_attrs, cookbook_a_template, cookbook_a_file ] }
    cookbook_a_manifest
  end

  let(:cookbook_a) do
    cookbook_a = Chef::CookbookVersion.new("cookbook_a")
    cookbook_a.manifest = cookbook_a_manifest
    cookbook_a
  end

  let(:cookbook_manifest) do
    {
      "cookbook_a" => cookbook_a,
    }
  end

  let(:events) { Chef::EventDispatch::Dispatcher.new }

  let(:no_lazy_load) { true }

  let(:skip_cookbook_sync) { false }

  let(:synchronizer) do
    Chef::Config[:no_lazy_load] = no_lazy_load
    Chef::Config[:file_cache_path] = "/file-cache"
    Chef::Config[:skip_cookbook_sync] = skip_cookbook_sync
    Chef::CookbookSynchronizer.new(cookbook_manifest, events)
  end

  it "lists the cookbook names" do
    expect(synchronizer.cookbook_names).to eq(%w{cookbook_a})
  end

  it "lists the cookbook manifests" do
    expect(synchronizer.cookbooks).to eq([cookbook_a])
  end

  context "#clear_obsoleted_cookbooks" do
    after do
      # Singletons == Global State == Bad
      Chef::CookbookCacheCleaner.instance.skip_removal = nil
    end

    it "behaves correctly when remove_obsoleted_files is false" do
      synchronizer.remove_obsoleted_files = false
      expect(synchronizer).not_to receive(:remove_old_cookbooks)
      expect(synchronizer).to receive(:remove_deleted_files)
      synchronizer.clear_obsoleted_cookbooks
      expect(Chef::CookbookCacheCleaner.instance.skip_removal).to be true
    end

    it "behaves correctly when remove_obsoleted_files is true" do
      synchronizer.remove_obsoleted_files = true
      expect(synchronizer).to receive(:remove_old_cookbooks)
      expect(synchronizer).to receive(:remove_deleted_files)
      synchronizer.clear_obsoleted_cookbooks
      expect(Chef::CookbookCacheCleaner.instance.skip_removal).to be nil
    end
  end

  context "#remove_old_cookbooks" do
    let(:file_cache) { double("Chef::FileCache with files from unused cookbooks") }

    let(:cookbook_manifest) do
      { "valid1" => {}, "valid2" => {} }
    end

    it "removes unneeded cookbooks" do
      valid_cached_cb_files = %w{cookbooks/valid1/recipes/default.rb cookbooks/valid2/recipes/default.rb}
      obsolete_cb_files = %w{cookbooks/old1/recipes/default.rb cookbooks/old2/recipes/default.rb}
      expect(file_cache).to receive(:find).with(File.join(%w{cookbooks ** {*,.*}})).and_return(valid_cached_cb_files + obsolete_cb_files)
      expect(file_cache).to receive(:delete).with("cookbooks/old1/recipes/default.rb")
      expect(file_cache).to receive(:delete).with("cookbooks/old2/recipes/default.rb")
      allow(synchronizer).to receive(:cache).and_return(file_cache)
      synchronizer.remove_old_cookbooks
    end
  end

  context "#remove_deleted_files" do
    let(:file_cache) { double("Chef::FileCache with files from unused cookbooks") }

    let(:cookbook_manifest) do
      { "valid1" => {}, "valid2" => {} }
    end

    it "removes only deleted files" do
      valid_cached_cb_files = %w{cookbooks/valid1/recipes/default.rb cookbooks/valid2/recipes/default.rb}
      obsolete_cb_files = %w{cookbooks/valid1/recipes/deleted.rb cookbooks/valid2/recipes/deleted.rb}
      expect(file_cache).to receive(:find).with(File.join(%w{cookbooks ** {*,.*}})).and_return(valid_cached_cb_files + obsolete_cb_files)
      # valid1 is a cookbook in our run_list
      expect(synchronizer).to receive(:have_cookbook?).with("valid1").at_least(:once).and_return(true)
      # valid2 is a cookbook not in our run_list (we're simulating an override run_list where valid2 needs to be preserved)
      expect(synchronizer).to receive(:have_cookbook?).with("valid2").at_least(:once).and_return(false)
      expect(file_cache).to receive(:delete).with("cookbooks/valid1/recipes/deleted.rb")
      expect(synchronizer).to receive(:cookbook_segment).with("valid1", "recipes").at_least(:once).and_return([ { "path" => "recipes/default.rb" }])
      allow(synchronizer).to receive(:cache).and_return(file_cache)
      synchronizer.remove_deleted_files
    end
  end

  let(:cookbook_a_default_recipe_tempfile) do
    double("Tempfile for cookbook_a default.rb recipe",
      path: "/tmp/cookbook_a_recipes_default_rb")
  end

  let(:cookbook_a_default_attribute_tempfile) do
    double("Tempfile for cookbook_a default.rb attr file",
      path: "/tmp/cookbook_a_attributes_default_rb")
  end

  let(:cookbook_a_file_default_tempfile) do
    double("Tempfile for cookbook_a megaman.conf file",
      path: "/tmp/cookbook_a_file_default_tempfile")
  end

  let(:cookbook_a_template_default_tempfile) do
    double("Tempfile for cookbook_a apache.conf.erb template",
      path: "/tmp/cookbook_a_template_default_tempfile")
  end

  let(:root) { windows? ? "C:/file-cache/cookbooks/cookbook_a" : "/file-cache/cookbooks/cookbook_a" }

  def setup_common_files_missing_expectations
    # Files are not in the cache:
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/recipes/default.rb")
      .and_return(false)
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/attributes/default.rb")
      .and_return(false)

    # Fetch and copy default.rb recipe
    expect(server_api).to receive(:streaming_request)
      .with("http://chef.example.com/abc123")
      .and_return(cookbook_a_default_recipe_tempfile)
    expect(file_cache).to receive(:move_to)
      .with("/tmp/cookbook_a_recipes_default_rb", "cookbooks/cookbook_a/recipes/default.rb")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/recipes/default.rb", false)
      .and_return("#{root}/recipes/default.rb")

    # Fetch and copy default.rb attribute file
    expect(server_api).to receive(:streaming_request)
      .with("http://chef.example.com/abc456")
      .and_return(cookbook_a_default_attribute_tempfile)
    expect(file_cache).to receive(:move_to)
      .with("/tmp/cookbook_a_attributes_default_rb", "cookbooks/cookbook_a/attributes/default.rb")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/attributes/default.rb", false)
      .and_return("#{root}/attributes/default.rb")
  end

  def setup_no_lazy_files_and_templates_missing_expectations
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/files/default/megaman.conf")
      .and_return(false)
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/templates/default/apache2.conf.erb")
      .and_return(false)

    expect(server_api).to receive(:streaming_request)
      .with("http://chef.example.com/megaman.conf")
      .and_return(cookbook_a_file_default_tempfile)
    expect(file_cache).to receive(:move_to)
      .with("/tmp/cookbook_a_file_default_tempfile", "cookbooks/cookbook_a/files/default/megaman.conf")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/files/default/megaman.conf", false)
      .and_return("#{root}/default/megaman.conf")

    expect(server_api).to receive(:streaming_request)
      .with("http://chef.example.com/ffffff")
      .and_return(cookbook_a_template_default_tempfile)
    expect(file_cache).to receive(:move_to)
      .with("/tmp/cookbook_a_template_default_tempfile", "cookbooks/cookbook_a/templates/default/apache2.conf.erb")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/templates/default/apache2.conf.erb", false)
      .and_return("#{root}/templates/default/apache2.conf.erb")
  end

  def setup_common_files_chksum_mismatch_expectations
    # Files are in the cache:
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/recipes/default.rb")
      .and_return(true)
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/attributes/default.rb")
      .and_return(true)

    # Fetch and copy default.rb recipe
    expect(server_api).to receive(:streaming_request)
      .with("http://chef.example.com/abc123")
      .and_return(cookbook_a_default_recipe_tempfile)
    expect(file_cache).to receive(:move_to)
      .with("/tmp/cookbook_a_recipes_default_rb", "cookbooks/cookbook_a/recipes/default.rb")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/recipes/default.rb", false)
      .twice
      .and_return("#{root}/recipes/default.rb")

    # Current file has fff000, want abc123
    expect(Chef::CookbookVersion).to receive(:checksum_cookbook_file)
      .with("#{root}/recipes/default.rb")
      .and_return("fff000").at_least(:once)

    # Fetch and copy default.rb attribute file
    expect(server_api).to receive(:streaming_request)
      .with("http://chef.example.com/abc456")
      .and_return(cookbook_a_default_attribute_tempfile)
    expect(file_cache).to receive(:move_to)
      .with("/tmp/cookbook_a_attributes_default_rb", "cookbooks/cookbook_a/attributes/default.rb")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/attributes/default.rb", false)
      .twice
      .and_return("#{root}/attributes/default.rb")

    # Current file has fff000, want abc456
    expect(Chef::CookbookVersion).to receive(:checksum_cookbook_file)
      .with("#{root}/attributes/default.rb")
      .and_return("fff000").at_least(:once)
  end

  def setup_no_lazy_files_and_templates_chksum_mismatch_expectations
    # Files are in the cache:
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/files/default/megaman.conf")
      .and_return(true)
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/templates/default/apache2.conf.erb")
      .and_return(true)

    # Fetch and copy megaman.conf
    expect(server_api).to receive(:streaming_request)
      .with("http://chef.example.com/megaman.conf")
      .and_return(cookbook_a_file_default_tempfile)
    expect(file_cache).to receive(:move_to)
      .with("/tmp/cookbook_a_file_default_tempfile", "cookbooks/cookbook_a/files/default/megaman.conf")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/files/default/megaman.conf", false)
      .twice
      .and_return("#{root}/default/megaman.conf")

    # Fetch and copy apache2.conf template
    expect(server_api).to receive(:streaming_request)
      .with("http://chef.example.com/ffffff")
      .and_return(cookbook_a_template_default_tempfile)
    expect(file_cache).to receive(:move_to)
      .with("/tmp/cookbook_a_template_default_tempfile", "cookbooks/cookbook_a/templates/default/apache2.conf.erb")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/templates/default/apache2.conf.erb", false)
      .twice
      .and_return("#{root}/templates/default/apache2.conf.erb")

    # Current file has fff000
    expect(Chef::CookbookVersion).to receive(:checksum_cookbook_file)
      .with("#{root}/default/megaman.conf")
      .and_return("fff000")

    # Current file has fff000
    expect(Chef::CookbookVersion).to receive(:checksum_cookbook_file)
      .with("#{root}/templates/default/apache2.conf.erb")
      .and_return("fff000")
  end

  def setup_common_files_present_expectations
    # Files are in the cache:
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/recipes/default.rb")
      .and_return(true)
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/attributes/default.rb")
      .and_return(true)

    # Current file has abc123, want abc123
    expect(Chef::CookbookVersion).to receive(:checksum_cookbook_file)
      .with("#{root}/recipes/default.rb")
      .and_return("abc123").at_least(:once)

    # Current file has abc456, want abc456
    expect(Chef::CookbookVersion).to receive(:checksum_cookbook_file)
      .with("#{root}/attributes/default.rb")
      .and_return("abc456").at_least(:once)

    # :load called twice
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/recipes/default.rb", false)
      .twice
      .and_return("#{root}/recipes/default.rb")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/attributes/default.rb", false)
      .twice
      .and_return("#{root}/attributes/default.rb")
  end

  def setup_no_lazy_files_and_templates_present_expectations
    # Files are in the cache:
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/files/default/megaman.conf")
      .and_return(true)
    expect(file_cache).to receive(:key?)
      .with("cookbooks/cookbook_a/templates/default/apache2.conf.erb")
      .and_return(true)

    # Current file has abc124, want abc124
    expect(Chef::CookbookVersion).to receive(:checksum_cookbook_file)
      .with("#{root}/default/megaman.conf")
      .and_return("abc124")

    # Current file has abc125, want abc125
    expect(Chef::CookbookVersion).to receive(:checksum_cookbook_file)
      .with("#{root}/templates/default/apache2.conf.erb")
      .and_return("abc125")

    # :load called twice
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/files/default/megaman.conf", false)
      .twice
      .and_return("#{root}/default/megaman.conf")
    expect(file_cache).to receive(:load)
      .with("cookbooks/cookbook_a/templates/default/apache2.conf.erb", false)
      .twice
      .and_return("#{root}/templates/default/apache2.conf.erb")
  end

  describe "#server_api" do
    it "sets keepalive to true" do
      expect(Chef::ServerAPI).to receive(:new).with(Chef::Config[:chef_server_url], keepalives: true)
      synchronizer.server_api
    end
  end

  describe "when syncing cookbooks with the server" do
    let(:server_api) { double("Chef::ServerAPI (mock)") }

    let(:file_cache) { double("Chef::FileCache (mock)") }

    before do
      # Would rather not stub out methods on the test subject, but setting up
      # the state is a PITA and tests for this behavior are above.
      allow(synchronizer).to receive(:clear_obsoleted_cookbooks)
      allow(synchronizer).to receive(:server_api).and_return(server_api)
      allow(synchronizer).to receive(:cache).and_return(file_cache)
    end

    context "when the cache does not contain the desired files" do
      before do
        setup_common_files_missing_expectations
      end

      context "Chef::Config[:no_lazy_load] is false" do
        let(:no_lazy_load) { false }

        it "fetches eagerly loaded files" do
          synchronizer.sync_cookbooks
        end

        it "does not fetch templates or cookbook files" do
          # Implicitly tested in previous test; this test is just for behavior specification.
          expect(server_api).not_to receive(:streaming_request)
            .with("http://chef.example.com/ffffff")

          synchronizer.sync_cookbooks
        end
      end

      context "Chef::Config[:no_lazy_load] is true" do
        let(:no_lazy_load) { true }

        before do
          setup_no_lazy_files_and_templates_missing_expectations
        end

        it "fetches templates and cookbook files" do
          synchronizer.sync_cookbooks
        end
      end
    end

    context "when the cache contains outdated files" do
      before do
        setup_common_files_chksum_mismatch_expectations
      end

      context "Chef::Config[:no_lazy_load] is true" do
        let(:no_lazy_load) { true }

        before do
          setup_no_lazy_files_and_templates_chksum_mismatch_expectations
        end

        it "updates the outdated files" do
          synchronizer.sync_cookbooks
        end
      end

      context "Chef::Config[:no_lazy_load] is false" do
        let(:no_lazy_load) { false }

        it "updates the outdated files" do
          synchronizer.sync_cookbooks
        end
      end
    end

    context "when the cache is up to date" do
      before do
        setup_common_files_present_expectations
      end

      context "Chef::Config[:no_lazy_load] is true" do
        let(:no_lazy_load) { true }

        before do
          setup_no_lazy_files_and_templates_present_expectations
        end

        it "does not update files" do
          expect(file_cache).not_to receive(:move_to)
          expect(server_api).not_to receive(:streaming_request)
          synchronizer.sync_cookbooks
        end
      end

      context "Chef::Config[:no_lazy_load] is false" do
        let(:no_lazy_load) { false }

        it "does not update files" do
          expect(file_cache).not_to receive(:move_to)
          expect(server_api).not_to receive(:streaming_request)
          synchronizer.sync_cookbooks
        end
      end
    end

    context "when Chef::Config[:skip_cookbook_sync] is true" do
      let(:skip_cookbook_sync) { true }

      it "loads the cookbook files and warns the user that this isn't supported" do
        expect(file_cache).to receive(:load)
          .with("cookbooks/cookbook_a/recipes/default.rb", false)
          .once
          .and_return("#{root}/recipes/default.rb")
        expect(file_cache).to receive(:load)
          .with("cookbooks/cookbook_a/attributes/default.rb", false)
          .once
          .and_return("#{root}/attributes/default.rb")
        expect(file_cache).to receive(:load)
          .with("cookbooks/cookbook_a/templates/default/apache2.conf.erb", false)
          .once
          .and_return("#{root}/templates/default/apache2.conf.erb")
        expect(file_cache).to receive(:load)
          .with("cookbooks/cookbook_a/files/default/megaman.conf", false)
          .once
          .and_return("#{root}/files/default/megaman.conf")
        expect(Chef::Log).to receive(:warn)
          .with("skipping cookbook synchronization! DO NOT LEAVE THIS ENABLED IN PRODUCTION!!!")
          .once
        synchronizer.sync_cookbooks
      end
    end
  end
end
