RSpec.describe MetasploitDataModels::Search::Visitor::Relation, type: :model do
  subject(:visitor) do
    described_class.new(
        :query => query
    )
  end

  let(:formatted) do
    # needs to be a valid operation so that query is valid
    "name:\"#{value}\""
  end

  let(:klass) {
    Mdm::Host
  }

  let(:query) do
    Metasploit::Model::Search::Query.new(
        :formatted => formatted,
        :klass => klass
    )
  end

  let(:value) {
    FactoryGirl.generate :mdm_host_name
  }

  it_should_behave_like 'Metasploit::Concern.run'

  context 'validations' do
    context 'query' do
      it { is_expected.to validate_presence_of(:query) }

      context 'valid' do
        let(:error) do
          I18n.translate('errors.messages.invalid')
        end

        let(:errors) do
          visitor.errors[:query]
        end

        context 'with query' do
          let(:query) do
            double('Query')
          end

          before(:example) do
            allow(query).to receive(:valid?).and_return(query)

            visitor.valid?
          end

          context 'with valid' do
            let(:valid) do
              true
            end

            it 'should not record error' do
              expect(errors).not_to include(error)
            end
          end

          context 'without valid' do
            let(:valid) do
              false
            end

            it 'should record error' do
              expect(errors).not_to include(error)
            end
          end
        end

        context 'without query' do
          let(:query) do
            nil
          end

          it 'should not record error' do
            expect(errors).not_to include(error)
          end
        end
      end
    end
  end

  context '#visit' do
    subject(:visit) do
      visitor.visit
    end

    context 'MetasploitDataModels::Search::Visitor::Includes' do
      subject(:includes_visitor) do
        visitor.visitor_by_relation_method[:includes]
      end

      it 'should visit Metasploit::Model::Search::Query#tree' do
        expect(includes_visitor).to receive(:visit).with(query.tree)

        visit
      end

      it 'should pass visited to ActiveRecord::Relation#includes' do
        visited = double('Visited')
        allow(includes_visitor).to receive(:visit).with(query.tree).and_return(visited)

        expect_any_instance_of(ActiveRecord::Relation).to receive(:includes).with(visited).and_return(query.klass.all)

        visit
      end
    end

    context 'MetasploitDataModels::Search::Visitor::Joins' do
      subject(:joins_visitor) do
        visitor.visitor_by_relation_method[:joins]
      end

      it 'should visit Metasploit::Model::Search::Query#tree' do
        expect(joins_visitor).to receive(:visit).with(query.tree)

        visit
      end

      it 'should pass visited to ActiveRecord::Relation#joins' do
        visited = double('Visited')
        allow(joins_visitor).to receive(:visit).with(query.tree).and_return(visited)

        expect_any_instance_of(ActiveRecord::Relation).to receive(:joins).with(visited).and_return(query.klass.all)

        visit
      end
    end

    context 'MetasploitDataModels::Search::Visitor::Where' do
      subject(:where_visitor) do
        visitor.visitor_by_relation_method[:where]
      end

      it 'should visit Metasploit::Model::Search::Query#tree' do
        expect(where_visitor).to receive(:visit).with(query.tree)

        visit
      end

      it 'should pass visited to ActiveRecord::Relation#includes' do
        visited = double('Visited')
        allow(where_visitor).to receive(:visit).with(query.tree).and_return(visited)

        expect_any_instance_of(ActiveRecord::Relation).to receive(:where).with(visited).and_return(query.klass.all)

        visit
      end
    end

    context 'matching record' do
      context 'Metasploit::Model::Search::Query#klass' do
        context 'with Mdm::Service' do
          include_context 'Rex::Text'

          #
          # lets
          #

          let(:klass) {
            Mdm::Service
          }

          #
          # Don't use factories to prevent prefix aliasing when sequences go from 1 to 10 or 10 to 100
          #

          let(:non_matching_host) {
            FactoryGirl.create(
                :mdm_host,
                address: non_matching_host_address,
                name: non_matching_host_name,
                os_flavor: non_matching_host_os_flavor,
                os_name: non_matching_host_os_name,
                os_sp: non_matching_host_os_sp
            ).tap { |host|
              FactoryGirl.create(
                  :mdm_host_tag,
                  host: host,
                  tag: non_matching_tag
              )
            }
          }

          let(:non_matching_host_address) {
            '5.6.7.8'
          }

          let(:non_matching_host_name) {
            'mdm_host_name_b'
          }

          let(:non_matching_host_os_flavor) {
            'mdm_host_os_flavor_b'
          }

          let(:non_matching_host_os_name) {
            'mdm_host_os_name_b'
          }

          let(:non_matching_host_os_sp) {
            'mdm_host_os_sp_b'
          }

          let(:non_matching_info) {
            'mdm_service_info_c'
          }

          let(:non_matching_name) {
            'mdm_service_name_c'
          }

          let(:non_matching_port) {
            3
          }

          let(:non_matching_proto) {
            'udp'
          }

          let(:non_matching_tag) {
            FactoryGirl.create(
                :mdm_tag,
                desc: non_matching_tag_desc,
                name: non_matching_tag_name
            )
          }

          let(:non_matching_tag_desc) {
            'Mdm::Tag#description b'
          }

          let(:non_matching_tag_name) {
            'mdm_tag_name.b'
          }

          #
          # let!s
          #

          let!(:non_matching_record) {
            FactoryGirl.create(
                :mdm_service,
                host: non_matching_host,
                info: non_matching_info,
                name: non_matching_name,
                port: non_matching_port,
                proto: non_matching_proto
            )
          }

          context 'with port' do
            #
            # lets
            #

            let(:matching_ports) {
              [
                  1,
                  2
              ]
            }

            let(:matching_records) {
              matching_record_by_port.values
            }

            #
            # let!s
            #

            let!(:matching_record_by_port) {
              matching_ports.each_with_object({}) { |matching_port, matching_record_by_port|
                matching_record_by_port[matching_port] = FactoryGirl.create(
                    :mdm_service,
                    port: matching_port
                )
              }
            }

            context 'with single port number' do
              let(:formatted) {
                "port:#{matching_port}"
              }

              let(:matching_port) {
                matching_ports.sample
              }

              let(:matching_record) {
                matching_record_by_port[matching_port]
              }

              it 'should find only record with that port number' do
                expect(visit).to match_array([matching_record])
              end
            end

            context 'with port range' do
              let(:formatted) {
                "port:#{matching_ports.min}-#{matching_ports.max}"
              }

              it 'should find all records with port numbers within the range' do
                expect(visit).to match_array(matching_records)
              end
            end

            context 'with comma separated port numbers' do
              let(:formatted) {
                "port:#{matching_ports.join(',')}"
              }

              it 'should find all records with the port numbers' do
                expect(visit).to match_array(matching_records)
              end
            end

            context 'with overlapping comma separated port number and range' do
              let(:matching_port) {
                matching_ports.sample
              }

              let(:formatted) {
                %Q{port:#{matching_port},#{matching_ports.min}-#{matching_ports.max}}
              }

              it 'should find all records with the matching ports once' do
                expect(visit).to match_array(matching_records)
              end
            end
          end

          context 'with single matching record' do
            #
            # lets
            #

            #
            # Don't use factories to prevent prefix aliasing when sequences go from 1 to 10 or 10 to 100
            #

            let(:matching_host) {
              FactoryGirl.create(
                  :mdm_host,
                  address: matching_host_address,
                  name: matching_host_name,
                  os_flavor: matching_host_os_flavor,
                  os_name: matching_host_os_name,
                  os_sp: matching_host_os_sp
              ).tap { |host|
                FactoryGirl.create(
                    :mdm_host_tag,
                    host: host,
                    tag: matching_tag
                )
              }
            }

            let(:matching_host_address) {
              '1.2.3.4'
            }

            let(:matching_host_name) {
              'mdm_host_name_a'
            }

            let(:matching_host_os_flavor) {
              'mdm_host_os_flavor_a'
            }

            let(:matching_host_os_name) {
              'mdm_host_os_name_a'
            }

            let(:matching_host_os_sp) {
              'mdm_host_os_sp_a'
            }

            let(:matching_info) {
              'mdm_service_info_a'
            }

            let(:matching_name) {
              'mdm_service_name_a'
            }

            let(:matching_port) {
              1
            }

            let(:matching_proto) {
              'tcp'
            }

            let(:matching_tag) {
              FactoryGirl.create(
                  :mdm_tag,
                  desc: matching_tag_desc,
                  name: matching_tag_name
              )
            }

            let(:matching_tag_desc) {
              'Mdm::Tag#description a'
            }

            let(:matching_tag_name) {
              'mdm_tag_name.a'
            }

            #
            # let!s
            #

            let!(:matching_record) {
              FactoryGirl.create(
                  :mdm_service,
                  host: matching_host,
                  info: matching_info,
                  name: matching_name,
                  port: matching_port,
                  proto: matching_proto
              )
            }

            context 'with host.address operator' do
              let(:formatted) do
                "host.address:#{formatted_address}"
              end

              context 'with CIDR' do
                let(:formatted_address) {
                  '1.3.4.5/8'
                }

                it 'should find only matching record' do
                  expect(visit).to match_array([matching_record])
                end
              end

              context 'with Range' do
                let(:formatted_address) {
                  '1.1.1.1-5.6.7.7'
                }

                it 'should find only matching record' do
                  expect(visit).to match_array([matching_record])
                end
              end

              context 'with single' do
                let(:formatted_address) {
                  '1.2.3.4'
                }

                it 'should find only matching record' do
                  expect(visit).to match_array([matching_record])
                end
              end
            end

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  association: :host,
                                  attribute: :name

            context 'with host.os' do
              let(:matching_host_os_flavor) {
                'XP'
              }

              let(:matching_host_os_name) {
                'Microsoft Windows'
              }

              let(:matching_host_os_sp) {
                'SP1'
              }

              context 'with a combination of Mdm::Host#os_name and Mdm:Host#os_sp' do
                let(:formatted) {
                  %Q{host.os:"win xp"}
                }

                it 'finds matching record' do
                  expect(visit).to match_array [matching_record]
                end
              end

              context 'with a combination of Mdm::Host#os_flavor and Mdm::Host#os_sp' do
                let(:formatted) {
                  %Q{host.os:"xp sp1"}
                }

                it 'finds matching record' do
                  expect(visit).to match_array [matching_record]
                end
              end

              context 'with multiple records matching one word' do
                let(:formatted) {
                  %Q{host.os:"win xp"}
                }

                let(:non_matching_host_os_name) {
                  'Microsoft Windows'
                }

                it 'finds only matching record by other words refining search' do
                  expect(visit).to match_array [matching_record]
                end
              end
            end

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  association: :host,
                                  attribute: :os_flavor

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  association: :host,
                                  attribute: :os_name

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  association: :host,
                                  attribute: :os_sp

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  association: {
                                      host: :tags
                                  },
                                  attribute: :desc

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  association: {
                                      host: :tags
                                  },
                                  attribute: :name

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  attribute: :info

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  attribute: :name

            it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                  attribute: :proto

            context 'with all operators' do
              let(:formatted) {
                %Q{
                  host.address:1.3.4.5/8
                  host.address:1.1.1.1-5.6.7.7
                  host.address:1.2.3.4
                  host.name:#{matching_host_name}
                  host.os:"#{matching_host_os_name} #{matching_host_os_flavor} #{matching_host_os_sp}"
                  host.os_flavor:#{matching_host_os_flavor}
                  host.os_name:#{matching_host_os_name}
                  host.os_sp:#{matching_host_os_sp}
                  host.tags.desc:"#{matching_tag_desc}"
                  host.tags.name:#{matching_tag_name}
                  name:#{matching_name}
                  port:#{matching_port}
                  proto:#{matching_proto}
                }
              }

              it 'finds only matching record' do
                expect(visit).to match_array([matching_record])
              end
            end
          end
        end

        context 'with Mdm::Host' do
          #
          # lets
          #
          # Don't use factories to prevent prefix aliasing when sequences go from 1 to 10 or 10 to 100
          #

          let(:matching_record_address) {
            '1.2.3.4'
          }

          let(:matching_record_os_flavor) {
            'mdm_host_os_flavor_a'
          }

          let(:matching_record_os_name) {
            'mdm_host_os_name_a'
          }

          let(:matching_record_os_sp) {
            'mdm_host_os_sp_a'
          }

          let(:matching_record_name) {
            'mdm_host_name_a'
          }

          let(:matching_service_name) {
            'mdm_service_name_a'
          }

          let(:non_matching_record_address) {
            '5.6.7.8'
          }

          let(:non_matching_record_os_flavor) {
            'mdm_host_os_flavor_b'
          }

          let(:non_matching_record_os_name) {
            'mdm_host_os_name_b'
          }

          let(:non_matching_record_os_sp) {
            'mdm_host_os_sp_b'
          }

          let(:non_matching_record_name) {
            'mdm_host_name_b'
          }

          let(:non_matching_service_name) {
            'mdm_service_name_b'
          }

          #
          # let!s
          #

          let!(:matching_record) do
            FactoryGirl.build(
                :mdm_host,
                address: matching_record_address,
                name: matching_record_name,
                os_flavor: matching_record_os_flavor,
                os_name: matching_record_os_name,
                os_sp: matching_record_os_sp
            )
          end

          let!(:matching_service) do
            FactoryGirl.create(
                :mdm_service,
                host: matching_record,
                name: matching_service_name
            )
          end

          let!(:non_matching_record) do
            FactoryGirl.build(
                :mdm_host,
                address: non_matching_record_address,
                name: non_matching_record_name,
                os_flavor: non_matching_record_os_flavor,
                os_name: non_matching_record_os_name,
                os_sp: non_matching_record_os_sp
            )
          end

          let!(:non_matching_service) do
            FactoryGirl.create(
                :mdm_service,
                host: non_matching_record,
                name: non_matching_service_name
            )
          end

          context 'with address operator' do
            let(:formatted) do
              "address:#{formatted_address}"
            end

            context 'with CIDR' do
              let(:formatted_address) {
                "'1.3.4.5/8'"
              }

              it 'should find only matching record' do
                expect(visit).to match_array([matching_record])
              end
            end

            context 'with Range' do
              let(:formatted_address) {
                '1.1.1.1-5.6.7.7'
              }

              it 'should find only matching record' do
                expect(visit).to match_array([matching_record])
              end
            end

            context 'with single' do
              let(:formatted_address) {
                '1.2.3.4'
              }

              it 'should find only matching record' do
                expect(visit).to match_array([matching_record])
              end
            end
          end

          it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                :attribute => :name

          context 'with os' do
            let(:matching_record_os_flavor) {
              'XP'
            }

            let(:matching_record_os_name) {
              'Microsoft Windows'
            }

            let(:matching_record_os_sp) {
              'SP1'
            }

            context 'with a combination of Mdm::Host#os_name and Mdm:Host#os_sp' do
              let(:formatted) {
                %Q{os:"win xp"}
              }

              it 'finds matching record' do
                expect(visit).to match_array [matching_record]
              end
            end

            context 'with a combination of Mdm::Host#os_flavor and Mdm::Host#os_sp' do
              let(:formatted) {
                %Q{os:"xp sp1"}
              }

              it 'finds matching record' do
                expect(visit).to match_array [matching_record]
              end
            end

            context 'with multiple records matching one word' do
              let(:formatted) {
                %Q{os:"win xp"}
              }

              let(:non_matching_record_os_name) {
                'Microsoft Windows'
              }

              it 'finds only matching record by other words refining search' do
                expect(visit).to match_array [matching_record]
              end
            end
          end

          it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                :attribute => :os_flavor

          it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                :attribute => :os_name

          it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                :attribute => :os_sp

          it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                association: :services,
                                attribute: :name

          context 'with all operators' do
            let(:formatted) {
              %Q{
              address:1.3.4.5/8
              address:1.1.1.1-5.6.7.7
              address:1.2.3.4
              name:"#{matching_record_name}"
              os:"#{matching_record_os_name} #{matching_record_os_flavor} #{matching_record_os_sp}"
              os_flavor:"#{matching_record_os_flavor}"
              os_name:"#{matching_record_os_name}"
              os_sp:"#{matching_record_os_sp}"
              services.name:"#{matching_service_name}"
            }
            }

            it 'should find only matching record' do
              if visit.to_a != [matching_record]
                true
              end

              expect(visit).to match_array([matching_record])
            end
          end
        end

        context 'with Mdm::Tag' do
          #
          # lets
          #

          let(:klass) {
            Mdm::Tag
          }

          let(:matching_desc) {
            'This is a description'
          }

          let(:matching_name) {
            'matching.tag'
          }

          let(:non_matching_desc) {
            'This could be a description'
          }

          let(:non_matching_name) {
            'tag.does.not.match'
          }

          #
          # let!s
          #

          let!(:matching_record) {
            FactoryGirl.create(
                :mdm_tag,
                desc: matching_desc,
                name: matching_name
            )
          }

          let!(:non_matching_record) {
            FactoryGirl.create(
                :mdm_tag,
                desc: non_matching_desc,
                name: non_matching_name
            )
          }

          it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                attribute: :desc

          it_should_behave_like 'MetasploitDataModels::Search::Visitor::Relation#visit matching record',
                                attribute: :name

          context 'with all operators' do
            let(:formatted) {
              %Q{desc:"#{matching_desc}" name:"#{matching_name}"}
            }

            it 'should find only matching record' do
              expect(visit).to match_array([matching_record])
            end
          end
        end
      end
    end
  end

  context '#visitor_by_relation_method' do
    subject(:visitor_by_relation_method) do
      visitor.visitor_by_relation_method
    end

    context 'joins' do
      subject(:joins) {
        visitor_by_relation_method[:joins]
      }

      it { is_expected.to be_a MetasploitDataModels::Search::Visitor::Joins }
    end

    context 'includes' do
      subject(:includes) {
        visitor_by_relation_method[:includes]
      }

      it { is_expected.to be_a MetasploitDataModels::Search::Visitor::Includes }
    end

    context 'where' do
      subject(:where) {
        visitor_by_relation_method[:where]
      }

      it { is_expected.to be_a MetasploitDataModels::Search::Visitor::Where }
    end
  end

  context 'visitor_class_by_relation_method' do
    subject(:visitor_class_by_relation_method) do
      described_class.visitor_class_by_relation_method
    end

    context 'joins' do
      subject(:joins) {
        visitor_class_by_relation_method[:joins]
      }

      it { is_expected.to eq(MetasploitDataModels::Search::Visitor::Joins) }
    end

    context 'includes' do
      subject(:includes) {
        visitor_class_by_relation_method[:includes]
      }

      it { is_expected.to eq(MetasploitDataModels::Search::Visitor::Includes) }
    end

    context 'where' do
      subject(:where) {
        visitor_class_by_relation_method[:where]
      }

      it { is_expected.to eq(MetasploitDataModels::Search::Visitor::Where) }
    end
  end
end
