# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1

require_relative "../base"

require "vagrant/util/platform"

describe VagrantPlugins::ProviderVirtualBox::Action::Network do
  include_context "unit"
  include_context "virtualbox"

  let(:iso_env) do
    # We have to create a Vagrantfile so there is a root path
    env = isolated_environment
    env.vagrantfile("")
    env.create_vagrant_env
  end

  let(:machine) do
    iso_env.machine(iso_env.machine_names[0], :virtualbox).tap do |m|
      allow(m.provider).to receive(:driver).and_return(driver)
    end
  end

  let(:env)    {{ machine: machine, ui: machine.ui }}
  let(:app)    { lambda { |*args| }}
  let(:driver) { double("driver", version: vbox_version) }
  let(:vbox_version) { "6.1.0" }

  let(:nics)         { {} }

  subject { described_class.new(app, env) }

  before do
    allow(driver).to receive(:enable_adapters)
    allow(driver).to receive(:read_network_interfaces)   { nics }
  end

  describe "#hostonly_config" do
    before do
      allow(subject).to receive(:hostonly_find_matching_network)
      allow(driver).to receive(:read_bridged_interfaces).and_return([])
      subject.instance_eval do
        def env=(e)
          @env = e
        end
      end

      subject.env = env
    end

    let(:options) {
      {
        type: type,
        ip: address,
      }
    }
    let(:type) { :dhcp }
    let(:address) { nil }

    it "should validate the IP" do
      expect(subject).to receive(:validate_hostonly_ip!)
      subject.hostonly_config(options)
    end

    context "when address is ipv6" do
      let(:address) { "::1" }

      context "when type is static6" do
        let(:type) { :static6 }

        it "should have a static6 type" do
          result = subject.hostonly_config(options)
          expect(result[:type]).to eq(:static6)
        end
      end

      context "when type is static" do
        let(:type) { :static }

        it "should have static6 type" do
          result = subject.hostonly_config(options)
          expect(result[:type]).to eq(:static6)
        end
      end
    end

    context "when name is provided as interface name" do
      let(:options) {
        {
          type: type,
          ip: address,
          name: name
        }
      }
      let(:name) { "hostonly_ifname" }
      let(:display_name) { "HostInterfaceNetworking-hostonly_ifname" }
      let(:hostonly_networks) do
        [
          {
            name: name,
            display_name: display_name
          }
        ]
      end

      before { allow(driver).to receive(:read_host_only_interfaces).and_return(hostonly_networks) }

      it "should lookup host only networks" do
        expect(driver).to receive(:read_host_only_interfaces).and_return(hostonly_networks)

        subject.hostonly_config(options)
      end

      it "should not change the name" do
        expect(subject.hostonly_config(options)[:name]) == name
      end

      context "when display name is provided in options" do
        let(:options) {
          {
            type: type,
            ip: address,
            name: display_name
          }
        }

        it "should change the name to the interface name" do
          expect(subject.hostonly_config(options)[:name]) == name
        end
      end
    end
  end

  describe "#validate_hostonly_ip!" do
    let(:address) { "192.168.1.2" }
    let(:net_conf) { [IPAddr.new(address + "/24")]}
    let(:vbox_version) { "6.1.28" }

    before do
      expect(subject).to receive(:validate_hostonly_ip!).and_call_original
    end

    context "when configuration file exists" do
      before do
        allow(subject).to receive(:load_net_conf).and_return(net_conf)
      end

      it "should load net configuration" do
        expect(subject).to receive(:load_net_conf).and_return(net_conf)
        subject.validate_hostonly_ip!(address, driver)
      end

      context "when address is within ranges" do
        it "should not error" do
          subject.validate_hostonly_ip!(address, driver)
        end
      end

      context "when address is not found within ranges" do
        let(:net_conf) { [IPAddr.new("127.0.0.1/20")] }

        it "should raise an error" do
          expect {
            subject.validate_hostonly_ip!(address, driver)
          }.to raise_error(Vagrant::Errors::VirtualBoxInvalidHostSubnet)
        end
      end

      context "when virtualbox version does not restrict range" do
        let(:vbox_version) { "6.1.20" }

        it "should not error" do
          subject.validate_hostonly_ip!(address, driver)
        end

        it "should not attempt to load network configuration" do
          expect(subject).not_to receive(:load_net_conf)
          subject.validate_hostonly_ip!(address, driver)
        end
      end

      context "when platform is windows" do
        before do
          allow(Vagrant::Util::Platform).to receive(:windows?).and_return(true)
        end

        it "should not error" do
          subject.validate_hostonly_ip!(address, driver)
        end

        it "should not attempt to load network configuration" do
          expect(subject).not_to receive(:load_net_conf)
          subject.validate_hostonly_ip!(address, driver)
        end
      end
    end

    context "when configuration file does not exist" do
      before do
        allow(File).to receive(:exist?).with(described_class.const_get(:VBOX_NET_CONF)).and_return(false)
      end

      context "when ipv4 address is within range" do
        let(:address) { "192.168.59.120" }

        it "should not error" do
          subject.validate_hostonly_ip!(address, driver)
        end
      end

      context "when ipv4 address is not within range" do
        let(:address) { "192.168.33.22" }

        it "should raise an error" do
          expect {
            subject.validate_hostonly_ip!(address, driver)
          }.to raise_error(Vagrant::Errors::VirtualBoxInvalidHostSubnet)
        end
      end

      context "when ipv6 address is within range" do
        let(:address) { "fe80:77:43:99:974:222:115:20" }

        it "should not error" do
          subject.validate_hostonly_ip!(address, driver)
        end
      end

      context "when ipv6 address not within range" do
        let(:address) { "33:77:43:99:974:222:115:20" }

        it "should raise an error" do
          expect {
            subject.validate_hostonly_ip!(address, driver)
          }.to raise_error(Vagrant::Errors::VirtualBoxInvalidHostSubnet)
        end
      end
    end
  end

  describe "#load_net_conf" do
    let(:file_contents) { [""] }

    before do
      allow(File).to receive(:exist?).and_call_original
      allow(File).to receive(:exist?).
        with(described_class.const_get(:VBOX_NET_CONF)).
        and_return(true)
      allow(File).to receive(:readlines).
        with(described_class.const_get(:VBOX_NET_CONF)).
        and_return(file_contents)
    end

    it "should read the configuration file" do
      expect(File).to receive(:readlines).
        with(described_class.const_get(:VBOX_NET_CONF)).
        and_return(file_contents)

      subject.load_net_conf
    end

    context "when file has comments only" do
      let(:file_contents) {
        [
          "# A comment",
          "# Another comment",
        ]
      }

      it "should return an empty array" do
        expect(subject.load_net_conf).to eq([])
      end
    end

    context "when file has valid range entries" do
      let(:file_contents) {
        [
          "* 127.0.0.1/24",
          "* 192.168.1.1/24",
        ]
      }

      it "should return an array with content" do
        expect(subject.load_net_conf).not_to be_empty
      end

      it "should include IPAddr instances" do
        subject.load_net_conf.each do |entry|
          expect(entry).to be_a(IPAddr)
        end
      end
    end

    context "when file has valid range entries and comments" do
      let(:file_contents) {
        [
          "# Comment in file",
          "* 127.0.0.0/8",
          "random text",
          " * 192.168.2.0/28",
        ]
      }

      it "should contain two entries" do
        expect(subject.load_net_conf.size).to eq(2)
      end
    end

    context "when file has multiple entries on single line" do
      let(:file_contents) {
        [
          "* 0.0.0.0/0 ::/0"
        ]
      }

      it "should contain two entries" do
        expect(subject.load_net_conf.size).to eq(2)
      end

      it "should contain an ipv4 and ipv6 range" do
        result = subject.load_net_conf
        expect(result.first).to be_ipv4
        expect(result.last).to be_ipv6
      end
    end
  end

  it "calls the next action in the chain" do
    called = false
    app = lambda { |*args| called = true }

    action = described_class.new(app, env)
    action.call(env)

    expect(called).to eq(true)
  end

  it "creates a host-only interface with an IPv6 address <prefix>:1" do
    guest = double("guest")
    machine.config.vm.network 'private_network', type: :static, ip: 'dead:beef::100'
    #allow(driver).to receive(:read_bridged_interfaces) { [] }
    allow(driver).to receive(:read_host_only_interfaces) { [] }
    #allow(driver).to receive(:read_dhcp_servers) { [] }
    allow(machine).to receive(:guest) { guest }
    allow(driver).to receive(:create_host_only_network) {{ name: 'vboxnet0' }}
    allow(guest).to receive(:capability)
    interface_ip = 'dead:beef::1'

    subject.call(env)

    expect(driver).to have_received(:create_host_only_network).with(hash_including({
      adapter_ip: interface_ip,
      netmask: 64,
    }))

    expect(guest).to have_received(:capability).with(:configure_networks, [{
      type: :static6,
      adapter_ip: 'dead:beef::1',
      ip: 'dead:beef::100',
      netmask: 64,
      auto_config: true,
      interface: nil
    }])
  end

  it "raises the appropriate error when provided with an invalid IP address" do
    machine.config.vm.network 'private_network', ip: '192.168.33.06'

    expect{ subject.call(env) }.to raise_error(Vagrant::Errors::NetworkAddressInvalid)
  end

  context "with a dhcp private network" do
    let(:bridgedifs)  { [] }
    let(:hostonlyifs) { [] }
    let(:dhcpservers) { [] }
    let(:guest)       { double("guest") }
    let(:network_args) {{ type: :dhcp }}

    before do
      machine.config.vm.network 'private_network', **network_args
      allow(driver).to receive(:read_bridged_interfaces) { bridgedifs }
      allow(driver).to receive(:read_host_only_interfaces) { hostonlyifs }
      allow(driver).to receive(:read_dhcp_servers) { dhcpservers }
      allow(machine).to receive(:guest) { guest }
    end

    it "tries to setup dhpc server using the ip for the specified network" do
      allow(driver).to receive(:create_host_only_network) {{ name: 'vboxnet0' }}
      allow(driver).to receive(:create_dhcp_server)
      allow(guest).to receive(:capability)
      allow(subject).to receive(:hostonly_find_matching_network).and_return({name: "vboxnet1", ip: "192.168.55.1"})

      subject.call(env)

      expect(driver).to have_received(:create_dhcp_server).with('vboxnet1', {
        adapter_ip: "192.168.55.1",
        auto_config: true,
        ip: "192.168.55.1",
        mac: nil,
        name: nil,
        netmask: "255.255.255.0",
        nic_type: nil,
        type: :dhcp,
        dhcp_ip: "192.168.55.2",
        dhcp_lower: "192.168.55.3",
        dhcp_upper: "192.168.55.254",
        adapter: 2
      })

      expect(guest).to have_received(:capability).with(:configure_networks, [{
        type: :dhcp,
        adapter_ip: "192.168.55.1",
        ip: "192.168.55.1",
        netmask: "255.255.255.0",
        auto_config: true,
        interface: nil
      }])
    end

    it "creates a host only interface and a dhcp server using default ips, then tells the guest to configure the network after boot" do
      allow(driver).to receive(:create_host_only_network) {{ name: 'vboxnet0' }}
      allow(driver).to receive(:create_dhcp_server)
      allow(guest).to receive(:capability)
      allow(subject).to receive(:hostonly_find_matching_network).and_return(nil)

      subject.call(env)

      expect(driver).to have_received(:create_host_only_network).with(hash_including({
        adapter_ip: '192.168.56.1',
        netmask: '255.255.255.0',
      }))

      expect(driver).to have_received(:create_dhcp_server).with('vboxnet0', {
        adapter_ip: "192.168.56.1",
        auto_config: true,
        ip: "192.168.56.1",
        mac: nil,
        name: nil,
        netmask: "255.255.255.0",
        nic_type: nil,
        type: :dhcp,
        dhcp_ip: "192.168.56.2",
        dhcp_lower: "192.168.56.3",
        dhcp_upper: "192.168.56.254",
        adapter: 2
      })

      expect(guest).to have_received(:capability).with(:configure_networks, [{
        type: :dhcp,
        adapter_ip: "192.168.56.1",
        ip: "192.168.56.1",
        netmask: "255.255.255.0",
        auto_config: true,
        interface: nil
      }])
    end

    context "when the default vbox dhcpserver is present from a fresh vbox install (see issue #3803)" do
      let(:dhcpservers) {[
        {
          network_name: 'HostInterfaceNetworking-vboxnet0',
          network: 'vboxnet0',
          ip: '192.168.56.100',
          netmask: '255.255.255.0',
          lower: '192.168.56.101',
          upper: '192.168.56.254'
        }
      ]}

      it "removes the invalid dhcpserver so it won't collide with any host only interface" do
        allow(driver).to receive(:remove_dhcp_server)
        allow(driver).to receive(:create_host_only_network) {{ name: 'vboxnet0' }}
        allow(driver).to receive(:create_dhcp_server)
        allow(guest).to receive(:capability)

        subject.call(env)

        expect(driver).to have_received(:remove_dhcp_server).with('HostInterfaceNetworking-vboxnet0')
      end

      context "but the user has intentionally configured their network just that way" do
        let (:network_args) {{
          type: :dhcp,
          adapter_ip: '192.168.56.1',
          dhcp_ip: '192.168.56.100',
          dhcp_lower: '192.168.56.101',
          dhcp_upper: '192.168.56.254'
        }}

        it "does not attempt to remove the dhcpserver" do
          allow(driver).to receive(:remove_dhcp_server)
          allow(driver).to receive(:create_host_only_network) {{ name: 'vboxnet0' }}
          allow(driver).to receive(:create_dhcp_server)
          allow(guest).to receive(:capability)

          subject.call(env)

          expect(driver).not_to have_received(:remove_dhcp_server).with('HostInterfaceNetworking-vboxnet0')
        end
      end
    end
  end

  context 'with invalid settings' do
    [
      { ip: 'foo'},
      { ip: '1.2.3'},
      { ip: 'dead::beef::'},
      { ip: '192.168.56.3', netmask: 64},
      { ip: '192.168.56.3', netmask: 'ffff:ffff::'},
      { ip: 'dead:beef::', netmask: 'foo:bar::'},
      { ip: 'dead:beef::', netmask: '255.255.255.0'}
    ].each do |args|
      it 'raises an exception' do
        machine.config.vm.network 'private_network', **args
        expect { subject.call(env) }.
          to raise_error(Vagrant::Errors::NetworkAddressInvalid)
      end
    end
  end

  context "without type set" do
    before { allow(subject).to receive(:hostonly_adapter).and_return({}) }

    [
      { ip: "192.168.63.5" },
      { ip: "192.168.63.5", netmask: "255.255.255.0" },
      { ip: "dead:beef::100" },
      { ip: "dead:beef::100", netmask: 96 },
    ].each do |args|
      it "sets the type automatically" do
        machine.config.vm.network "private_network", **args
        expect(subject).to receive(:hostonly_config) do |config|
          expect(config).to have_key(:type)
          addr = IPAddr.new(args[:ip])
          if addr.ipv4?
            expect(config[:type]).to eq(:static)
          else
            expect(config[:type]).to eq(:static6)
          end
          config
        end
        subject.call(env)

      end
    end
  end

  describe "#hostonly_find_matching_network" do
    let(:ip){ "192.168.55.2" }
    let(:config){ {ip: ip, netmask: "255.255.255.0"} }
    let(:interfaces){ [] }

    before do
      allow(driver).to receive(:read_host_only_interfaces).and_return(interfaces)
      subject.instance_variable_set(:@env, env)
    end

    context "with no defined host interfaces" do
      it "should return nil" do
        expect(subject.hostonly_find_matching_network(config)).to be_nil
      end
    end

    context "with matching host interface" do
      let(:interfaces){ [{ip: "192.168.55.1", netmask: "255.255.255.0", name: "vnet"}] }

      it "should return matching interface" do
        expect(subject.hostonly_find_matching_network(config)).to eq(interfaces.first)
      end

      context "with matching name" do
        let(:config){ {ip: ip, netmask: "255.255.255.0", name: "vnet"} }

        it "should return matching interface" do
          expect(subject.hostonly_find_matching_network(config)).to eq(interfaces.first)
        end
      end

      context "with non-matching name" do
        let(:config){ {ip: ip, netmask: "255.255.255.0", name: "unknown"} }

        it "should return nil" do
          expect(subject.hostonly_find_matching_network(config)).to be_nil
        end
      end
    end
  end
end
