x-pack/spec/geoip_database_management/downloader_spec.rb (233 lines of code) (raw):
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
# or more contributor license agreements. Licensed under the Elastic License;
# you may not use this file except in compliance with the Elastic License.
describe LogStash::GeoipDatabaseManagement::Downloader, aggregate_failures: true, verify_stubs: true do
let(:temp_metadata_path) { Stud::Temporary.directory }
let(:data_path) { LogStash::GeoipDatabaseManagement::DataPath.new(temp_metadata_path) }
let(:metadata) { LogStash::GeoipDatabaseManagement::Metadata.new(data_path) }
let(:service_host) { "https://geoip.elastic.dev" }
let(:service_path) { "v1/database" }
let(:service_endpoint) { "#{service_host}/#{service_path}" }
let(:database_type) { constants::CITY }
let(:constants) { LogStash::GeoipDatabaseManagement::Constants }
subject(:downloader) { described_class.new(metadata, service_endpoint) }
after(:each) do
FileUtils::rm_rf(temp_metadata_path)
end
context "rest client" do
it "can call endpoint" do
conn = downloader.send(:rest_client)
res = conn.get(downloader.list_databases_url)
expect(res.code).to eq(200)
end
it 'raises error when endpoint response 4xx' do
bad_uri = "#{service_host}/?key=#{SecureRandom.uuid}&elastic_geoip_service_tos=agree"
expect(downloader).to receive(:list_databases_url).and_return(bad_uri).twice
expect { downloader.send(:check_update, constants::DB_TYPES) }.to raise_error(described_class::BadResponseCodeError, /404/)
end
context "when ENV['http_proxy'] is set" do
let(:mock_resp) { JSON.parse(::File.read(::File.expand_path("./fixtures/normal_resp.json", ::File.dirname(__FILE__)))) }
let(:db_info) { mock_resp.find {|i| i["name"].include?(database_type) } }
let(:proxy_url) { 'http://user:pass@example.com:1234' }
around(:each) { |example| with_environment('http_proxy' => proxy_url, &example) }
it "initializes the rest client with the proxy" do
expect(::Manticore::Client).to receive(:new).with(a_hash_including(:proxy => proxy_url)).and_call_original
downloader.send(:rest_client)
end
it "download database with the proxy" do
dirname = Time.now.to_i.to_s
expected_gz_download_location = data_path.gz(database_type, dirname)
expect(downloader).to receive(:md5).with(expected_gz_download_location).and_return(db_info['md5_hash'])
expect(::Down).to receive(:download).with(db_info['url'], a_hash_including(:proxy => proxy_url)).and_return(true)
downloader.send(:download_database, database_type, dirname, db_info)
end
end
end
context 'check_update' do
let(:mock_resp_decoded) { JSON.parse(mock_resp_body) }
let(:mock_resp_body) { ::File.read(::File.expand_path("./fixtures/normal_resp.json", ::File.dirname(__FILE__))) }
let(:mock_resp) { double("list_databases_response", :body => mock_resp_body, code: 200)}
let(:asn_info) { mock_resp_decoded.find { |i| i["name"].include?(constants::ASN) } }
let(:city_info) { mock_resp_decoded.find { |i| i["name"].include?(constants::CITY) } }
before(:each) do
allow(downloader).to receive_message_chain('rest_client.get').and_return(mock_resp)
end
it "returns City db info when City md5 does not match" do
metadata_city_gzmd5 = SecureRandom.hex(20)
expect(metadata).to receive(:database_path).with(constants::CITY).and_return("/this/that/GeoLite2-City.mmdb")
expect(metadata).to receive(:gz_md5).with(constants::CITY).and_return(metadata_city_gzmd5)
expect(metadata).to receive(:database_path).with(constants::ASN).and_return("/this/that/GeoLite2-ASN.mmdb")
expect(metadata).to receive(:gz_md5).with(constants::ASN).and_return(asn_info['md5_hash'])
updated_dbs = downloader.send(:check_update, constants::DB_TYPES)
expect(updated_dbs.size).to eql(1)
type, info = updated_dbs[0]
expect(info).to have_key("md5_hash")
expect(info).to have_key("name")
expect(info).to have_key("provider")
expect(info).to have_key("updated")
expect(info).to have_key("url")
expect(type).to eql(constants::CITY)
end
it "returns empty array when all md5's match" do
expect(metadata).to receive(:database_path).with(constants::CITY).and_return("/this/that/GeoLite2-City.mmdb")
expect(metadata).to receive(:gz_md5).with(constants::CITY).and_return(city_info['md5_hash'])
expect(metadata).to receive(:database_path).with(constants::ASN).and_return("/this/that/GeoLite2-ASN.mmdb")
expect(metadata).to receive(:gz_md5).with(constants::ASN).and_return(asn_info['md5_hash'])
updated_dbs = downloader.send(:check_update, constants::DB_TYPES)
expect(updated_dbs.size).to eql(0)
end
it "returns City db info when City db not in metadata" do
expect(metadata).to receive(:database_path).with(constants::CITY).and_return(nil) # signal missing file
expect(metadata).to receive(:database_path).with(constants::ASN).and_return("/this/that/GeoLite2-ASN.mmdb")
expect(metadata).to receive(:gz_md5).with(constants::ASN).and_return(asn_info['md5_hash'])
updated_dbs = downloader.send(:check_update, constants::DB_TYPES)
expect(updated_dbs.size).to eql(1)
type, info = updated_dbs[0]
expect(info).to have_key("md5_hash")
expect(info).to have_key("name")
expect(info).to have_key("provider")
expect(info).to have_key("updated")
expect(info).to have_key("url")
expect(type).to eql(constants::CITY)
end
end
context "download database" do
let(:db_info) do
{
"age" => 297221,
"md5_hash" => md5_hash,
"name" => filename,
"provider" => "maxmind",
"updated" => 1609891257,
"url" => expected_download_url
}
end
let(:md5_hash) { SecureRandom.hex }
let(:filename) { "GeoLite2-City.tgz"}
let(:dirname) { "0123456789" }
let(:expected_download_url) { "#{service_host}/blob/sample.tgz" }
let(:sample_city_db_gz) { ::File.expand_path("./fixtures/sample.tgz", ::File.dirname(__FILE__)) }
before(:each) do
allow(Down).to receive(:download).with(expected_download_url, anything) do |url, options|
FileUtils::cp(sample_city_db_gz, options[:destination])
true
end
end
context "with mismatched md5 checksum" do
let(:md5_hash) { SecureRandom.hex }
it "should raise error if md5 does not match" do
expect { downloader.send(:download_database, database_type, dirname, db_info) }.to raise_error /wrong checksum/
end
end
context "with matching md5 checksum" do
let(:md5_hash) { LogStash::GeoipDatabaseManagement::Util.md5(sample_city_db_gz) }
it "should download file and return zip path" do
new_zip_path = downloader.send(:download_database, database_type, dirname, db_info)
expect(new_zip_path).to match /GeoLite2-City\.tgz/
expect(::File.exist?(new_zip_path)).to be_truthy
end
end
end
context "unzip" do
let(:dirname) { Time.now.to_i.to_s }
let(:copyright_path) { data_path.resolve(dirname, 'COPYRIGHT.txt') }
let(:license_path) { data_path.resolve(dirname, 'LICENSE.txt') }
let(:readme_path) { data_path.resolve(dirname, 'README.txt') }
let(:folder_path) { data_path.resolve(dirname, 'inner') }
let(:folder_more_path) { data_path.resolve(dirname, 'inner', 'more.txt') }
let(:folder_less_path) { data_path.resolve(dirname, 'inner', 'less.txt') }
before do
FileUtils.mkdir_p(data_path.resolve(dirname))
end
it "should extract all files in tarball" do
zip_path = ::File.expand_path("./fixtures/sample.tgz", ::File.dirname(__FILE__))
new_db_path = downloader.send(:unzip, database_type, dirname, zip_path)
expect(new_db_path).to match /GeoLite2-#{database_type}\.mmdb/
expect(::File.exist?(new_db_path)).to be_truthy
expect(::File.exist?(copyright_path)).to be_truthy
expect(::File.exist?(license_path)).to be_truthy
expect(::File.exist?(readme_path)).to be_truthy
expect(::File.directory?(folder_path)).to be_truthy
expect(::File.exist?(folder_more_path)).to be_truthy
expect(::File.exist?(folder_less_path)).to be_truthy
end
end
context "assert_database!" do
let(:sample_city_db_gz) { ::File.expand_path("./fixtures/sample.tgz", ::File.dirname(__FILE__)) }
it "rejects files that don't exist" do
expect { downloader.send(:assert_database!, data_path.resolve("nope.mmdb") ) }.to raise_exception(/does not exist/)
end
it "rejects files that aren't MMDB" do
expect { downloader.send(:assert_database!, __FILE__ ) }.to raise_exception(/does not appear to be a MaxMind DB/)
end
it "accepts files that have MMDB marker" do
candidate = data_path.db(constants::CITY, "expanded")
FileUtils.mkdir_p(data_path.resolve("expanded"))
# A file that has the magic MaxMind marker buried inside it
::File.open(candidate, 'w:BINARY') do |handle|
handle.write("#{database_type}".b)
handle.write(SecureRandom.bytes(rand(2048...10240)).b)
handle.write("#\xab\xcd\xefMaxMind.com".b)
handle.write(SecureRandom.bytes(rand(2048...10240)).b)
handle.write("#{database_type}".b)
handle.flush
end
downloader.send(:assert_database!, candidate)
end
end
context "fetch_databases" do
it "should return array of db which has valid download" do
expect(downloader).to receive(:check_update).and_return([[constants::ASN, {}],
[constants::CITY, {}]])
allow(downloader).to receive(:download_database)
allow(downloader).to receive(:unzip).and_return("NEW_DATABASE_PATH")
expect(downloader).to receive(:assert_database!).at_least(:once)
updated_db = downloader.send(:fetch_databases, constants::DB_TYPES)
expect(updated_db.size).to eql(2)
asn_type, asn_valid_download, asn_dirname, asn_path = updated_db[0]
city_type, city_valid_download, city_dirname, city_path = updated_db[1]
expect(asn_valid_download).to be_truthy
expect(asn_path).to eql("NEW_DATABASE_PATH")
expect(city_valid_download).to be_truthy
expect(city_path).to eql("NEW_DATABASE_PATH")
end
it "should return array of db which has invalid download" do
expect(downloader).to receive(:check_update).and_return([[constants::ASN, {}],
[constants::CITY, {}]])
expect(downloader).to receive(:download_database).and_raise('boom').at_least(:twice)
updated_db = downloader.send(:fetch_databases, constants::DB_TYPES)
expect(updated_db.size).to eql(2)
asn_type, asn_valid_download, asn_path = updated_db[0]
city_type, city_valid_download, city_path = updated_db[1]
expect(asn_valid_download).to be_falsey
expect(asn_path).to be_nil
expect(city_valid_download).to be_falsey
expect(city_path).to be_nil
end
end
context "#resolve_download_url" do
context "when given an absolute URL" do
let(:absolute_url) { "https://example.com/blob/this.tgz" }
it 'returns the provided URL' do
expect(downloader.send(:resolve_download_url, absolute_url).to_s).to eq(absolute_url)
end
end
context "when given a relative URL with absolute path" do
let(:relative_url) { "/blob/this.tgz" }
it 'returns a url resolved relative to service endpoint' do
expect(downloader.send(:resolve_download_url, relative_url).to_s).to eq("#{service_host}#{relative_url}")
end
end
context "when given a relative URL with relative path" do
let(:relative_url) { "blob/this.tgz" }
it 'returns a url resolved relative to service endpoint' do
expect(downloader.send(:resolve_download_url, relative_url).to_s).to eq("#{service_host}/v1/#{relative_url}")
end
end
end
context "#list_databases_url" do
subject(:list_databases_url) { downloader.list_databases_url }
it "adds the key and tos agreement parameters" do
expect(list_databases_url.host).to eq("geoip.elastic.dev")
expect(list_databases_url.path).to eq("/v1/database")
expect(list_databases_url.query).to include "key=#{downloader.send(:uuid)}"
expect(list_databases_url.query).to include "elastic_geoip_service_tos=agree"
end
end
end