x-pack/spec/geoip_database_management/manager_spec.rb (441 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::Manager, aggregate_failures: true, verify_stubs: true do
def write_dummy_mmdb(type, path)
FileUtils.mkdir_p(File::dirname(path))
File.open(path, "w:BINARY") do |handle|
handle.write("#{type}\xab\xcd\xefMaxMind.com#{type}".force_encoding("BINARY"))
end
end
let(:manager_instance) do
apply_settings(settings_overrides) do |applied_settings|
stub_const("LogStash::SETTINGS", applied_settings)
Class.new(described_class) do
public :setup
public :shutdown!
public :current_db_info
public :current_state
public :execute_download_job
public :metadata
public :downloader
end.instance
end
end
let(:constants) { LogStash::GeoipDatabaseManagement::Constants }
let(:settings_overrides) do
{
'path.data' => settings_path_data,
}
end
let(:settings_path_data) { Stud::Temporary.directory }
let(:geoip_data_path) { ::File.expand_path("geoip_database_management", settings_path_data)}
let(:geoip_metadata_path) { ::File.expand_path("metadata.csv", geoip_data_path) }
let(:populate_geoip_data_path) { true }
let(:metadata_contents) { nil }
before(:each) do
if populate_geoip_data_path
::FileUtils.mkdir_p(::File.dirname(geoip_metadata_path))
::File.write(geoip_metadata_path, metadata_contents) unless metadata_contents.nil?
end
end
after(:each) do
manager_instance.shutdown!
FileUtils.rm_rf(settings_path_data)
end
shared_context "existing databases from metadata" do
let(:existing_dirname) { (Time.now.to_i - 1000).to_s }
let(:existing_city_db_check_at) { Time.now.to_i - 100 }
let(:existing_city_gzmd5) { SecureRandom::hex(20) }
let(:existing_city_db_path) { ::File.join(geoip_data_path, existing_dirname, "GeoLite2-City.mmdb") }
let(:existing_asn_db_check_at) { Time.now.to_i - 100 }
let(:existing_asn_gzmd5) { SecureRandom::hex(20) }
let(:existing_asn_db_path) { ::File.join(geoip_data_path, existing_dirname, "GeoLite2-ASN.mmdb") }
before(:each) do
write_dummy_mmdb(constants::CITY, existing_city_db_path) unless existing_city_db_path.nil?
write_dummy_mmdb(constants::ASN, existing_asn_db_path) unless existing_asn_db_path.nil?
end
let(:metadata_contents) do
<<~EOMETA
#{constants::CITY},#{existing_city_db_check_at},#{existing_city_gzmd5},#{existing_dirname}
#{constants::ASN},#{existing_asn_db_check_at},#{existing_asn_gzmd5},#{existing_dirname}
EOMETA
end
end
context "pre-use" do
before(:each) do
expect_any_instance_of(described_class).to_not receive(:execute_download_job)
end
it 'is not running' do
expect(manager_instance).to_not be_running
end
context "when disabled" do
let(:settings_overrides) { super().merge('xpack.geoip.downloader.enabled' => false) }
include_context "existing databases from metadata"
let(:mock_logger) { double('logger').as_null_object }
before(:each) do
allow(described_class).to receive(:logger).and_return(mock_logger)
end
it 'logs info about removing managed databases' do
manager_instance # instantiate
expect(mock_logger).to have_received(:info).with(a_string_including "removing managed databases from disk")
end
it 'removes on-disk metadata' do
manager_instance # instantiate
expect(manager_instance.metadata).to_not exist
expect(Pathname(geoip_metadata_path)).to_not be_file
end
it 'removes on-disk databases' do
manager_instance # instantiate
expect(Pathname(existing_city_db_path)).to_not be_file
expect(Pathname(existing_asn_db_path)).to_not be_file
end
context "and data directory does not exist" do
let(:populate_geoip_data_path) { false }
let(:existing_city_db_path) { nil }
let(:existing_asn_db_path) { nil }
it 'logs info about database manager being disabled' do
manager_instance # instantiate
expect(mock_logger).to have_received(:info).with(a_string_including "database manager is disabled")
end
end
end
end
context "once started" do
before(:each) do
allow(manager_instance).to receive(:execute_download_job).and_return(nil)
manager_instance.send(:ensure_started!)
end
it 'is running' do
expect(manager_instance).to be_running
expect(manager_instance).to have_received(:execute_download_job)
end
it 'has a data directory' do
expect(Pathname(geoip_data_path)).to be_directory
end
it 'has metadata' do
expect(Pathname(::File.expand_path("metadata.csv", geoip_data_path))).to be_file
end
end
context "#supported_database_types" do
subject(:supported_database_types) { manager_instance.supported_database_types }
it 'includes City' do
expect(supported_database_types).to include(constants::CITY)
end
it 'includes ASN' do
expect(supported_database_types).to include(constants::ASN)
end
it 'returns only frozen strings' do
expect(supported_database_types).to all( be_a_kind_of String )
expect(supported_database_types).to all( be_frozen )
end
end
context "#subscribe_database_path" do
context "and manager is not enabled" do
let(:settings_overrides) { super().merge('xpack.geoip.downloader.enabled' => false) }
it "returns nil" do
expect(manager_instance.subscribe_database_path(constants::CITY)).to be_nil
end
end
shared_examples "active subscription" do |database_type|
it 'receives expiry notifications' do
allow(subscription).to receive(:notify).and_call_original
manager_instance.current_state(database_type).expire!
expect(subscription)
.to have_received(:notify)
.with(an_object_having_attributes({:expired? => true,
:path => nil,
:removed? => true}))
end
it 'receives update notifications' do
allow(subscription).to receive(:notify).and_call_original
updated_db_path = ::File.join(geoip_data_path, Time.now.to_i.to_s, "GeoLite2-#{database_type}.mmdb")
write_dummy_mmdb(database_type, updated_db_path)
manager_instance.current_state(database_type).update!(updated_db_path)
expect(subscription)
.to have_received(:notify)
.with(an_object_having_attributes({:expired? => false,
:path => updated_db_path}))
end
end
context "when metadata exists" do
include_context "existing databases from metadata"
before(:each) do
allow(manager_instance).to receive(:execute_download_job).and_return(nil)
end
context "the returned subscription" do
subject(:subscription) { manager_instance.subscribe_database_path(constants::CITY) }
it 'carries the path of the DB from metadata' do
expect(subscription.value).to have_attributes(:path => existing_city_db_path)
end
include_examples "active subscription", LogStash::GeoipDatabaseManagement::Constants::CITY
end
context "and metadata references an mmdb that has been removed" do
let(:existing_city_db_path) { nil } # prevent write
context "the returned subscription" do
subject(:subscription) { manager_instance.subscribe_database_path(constants::CITY) }
it 'indicates that the DB has been removed' do
expect(subscription.value).to be_removed
end
include_examples "active subscription", LogStash::GeoipDatabaseManagement::Constants::CITY
end
end
context "and metadata does not contain an entry for the specified DB" do
let(:metadata_contents) do
<<~EOMETA
#{constants::ASN},#{existing_asn_db_check_at},#{existing_asn_gzmd5},#{existing_dirname}
EOMETA
end
context "the returned subscription" do
subject(:subscription) { manager_instance.subscribe_database_path(constants::CITY) }
it 'indicates that the DB is pending' do
expect(subscription.value).to be_pending
end
include_examples "active subscription", LogStash::GeoipDatabaseManagement::Constants::CITY
end
end
end
context "when metadata does not yet exist" do
before(:each) do
allow(manager_instance).to receive(:execute_download_job).and_return(nil)
end
context "the returned subscription" do
subject(:subscription) { manager_instance.subscribe_database_path(constants::CITY) }
it 'is marked as pending' do
expect(subscription.value).to be_pending
end
include_examples "active subscription", LogStash::GeoipDatabaseManagement::Constants::CITY
end
end
end
context "execute_download_job" do
let(:mock_logger) { double('logger').as_null_object }
before(:each) do
allow(manager_instance).to receive(:logger).and_return(mock_logger)
expect(manager_instance).to receive(:downloader).and_return(mock_downloader)
end
let(:downloader_response) { [] }
let(:mock_downloader) do
double("downloader").tap do |downloader|
allow(downloader).to receive(:fetch_databases).with(constants::DB_TYPES).and_return(downloader_response)
allow(downloader).to receive(:uuid).and_return(SecureRandom.uuid)
end
end
let(:updated_dirname) { (Time.now.to_i - 1).to_s }
let(:updated_city_db_path) { ::File.join(geoip_data_path, updated_dirname, "GeoLite2-City.mmdb")}
let(:updated_asn_db_path) { ::File.join(geoip_data_path, updated_dirname, "GeoLite2-ASN.mmdb")}
shared_examples "ASN near expiry warning" do
context "when a near-expiry ASN database is not succesfully updated" do
let(:existing_asn_db_check_at) { Time.now.to_i - (27 * 24 * 60 * 60) } # 27 days ago
it 'retains ASN state' do
allow(manager_instance.current_state(constants::ASN)).to receive(:update!).and_call_original
manager_instance.execute_download_job
expected_asn_attributes = {
:path => existing_asn_db_path, :pending? => false, :expired? => false, :removed? => false
}
expect(manager_instance.current_db_info(constants::ASN)).to have_attributes(expected_asn_attributes)
expect(manager_instance.current_state(constants::ASN)).to_not have_received(:update!)
end
it "emits a warning log about pending ASN expiry" do
manager_instance.execute_download_job
expect(manager_instance.logger).to have_received(:warn).with(a_string_including "MaxMind GeoIP ASN database hasn't been synchronized in 27 days")
end
end
end
shared_examples "ASN past expiry eviction" do
context "when a past-expiry ASN database is not successfully updated" do
let(:existing_asn_db_check_at) { Time.now.to_i - (31 * 24 * 60 * 60) } # 31 days ago
it 'expires the ASN state' do
allow(manager_instance.current_state(constants::ASN)).to receive(:expire!).and_call_original
manager_instance.execute_download_job
expected_asn_attributes = {
:path => nil, :pending? => false, :expired? => true, :removed? => true
}
expect(manager_instance.current_db_info(constants::ASN)).to have_attributes(expected_asn_attributes)
expect(manager_instance.current_state(constants::ASN)).to have_received(:expire!)
end
it "emits an error log about ASN expiry eviction" do
manager_instance.execute_download_job
expect(manager_instance.logger).to have_received(:error).with(a_string_including("MaxMind GeoIP ASN database hasn't been synchronized in 31 days").and(including("removed")))
end
it "removes the expired ASN dbpath from metadata" do
manager_instance.execute_download_job
expect(manager_instance.metadata.database_path(constants::ASN)).to be_nil
end
end
end
shared_examples "ASN updated" do
it "updates ASN state" do
allow(manager_instance.current_state(constants::ASN)).to receive(:update!).and_call_original
manager_instance.execute_download_job
manager_instance.current_db_info(constants::ASN).tap do |asn_db_info|
expect(asn_db_info.path).to eq(updated_asn_db_path)
expect(asn_db_info).to_not be_pending
expect(asn_db_info).to_not be_expired
expect(asn_db_info).to_not be_removed
end
expect(manager_instance.current_state(constants::ASN)).to have_received(:update!).with(updated_asn_db_path)
end
it "updates ASN metadata" do
manager_instance.execute_download_job
expect(manager_instance.metadata.database_path(constants::ASN)).to eq(updated_asn_db_path)
expect(manager_instance.metadata.check_at(constants::ASN)).to satisfy { |x| Time.now.to_i - x <= 1 }
end
end
shared_examples "ASN unchanged" do
it "retains ASN state" do
allow(manager_instance.current_state(constants::ASN)).to receive(:update!).and_call_original
manager_instance.execute_download_job
manager_instance.current_db_info(constants::ASN).tap do |asn_db_info|
expect(asn_db_info.path).to eq(existing_asn_db_path)
expect(asn_db_info).to_not be_pending
expect(asn_db_info).to_not be_expired
expect(asn_db_info).to_not be_removed
end
expect(manager_instance.current_state(constants::ASN)).to_not have_received(:update!)
end
it "updates ASN metadata check_at" do
manager_instance.execute_download_job
expect(manager_instance.metadata.database_path(constants::ASN)).to eq(existing_asn_db_path)
expect(manager_instance.metadata.check_at(constants::ASN)).to satisfy { |x| Time.now.to_i - x <= 1 }
end
end
shared_examples "ASN errored" do
it "retains ASN state" do
allow(manager_instance.current_state(constants::ASN)).to receive(:update!).and_call_original
manager_instance.execute_download_job
manager_instance.current_db_info(constants::ASN).tap do |asn_db_info|
expect(asn_db_info.path).to eq(existing_asn_db_path)
expect(asn_db_info).to_not be_pending
expect(asn_db_info).to_not be_expired
expect(asn_db_info).to_not be_removed
end
expect(manager_instance.current_state(constants::ASN)).to_not have_received(:update!)
end
it "retains ASN metadata check_at" do
manager_instance.execute_download_job
expect(manager_instance.metadata.database_path(constants::ASN)).to eq(existing_asn_db_path)
expect(manager_instance.metadata.check_at(constants::ASN)).to eq(existing_asn_db_check_at)
end
end
shared_examples "City updated" do
it "updates City state" do
allow(manager_instance.current_state(constants::CITY)).to receive(:update!).and_call_original
manager_instance.execute_download_job
manager_instance.current_db_info(constants::CITY).tap do |city_db_info|
expect(city_db_info.path).to eq(updated_city_db_path)
expect(city_db_info).to_not be_pending
expect(city_db_info).to_not be_expired
expect(city_db_info).to_not be_removed
end
expect(manager_instance.current_state(constants::CITY)).to have_received(:update!).with(updated_city_db_path)
end
it "updates City metadata" do
manager_instance.execute_download_job
expect(manager_instance.metadata.database_path(constants::CITY)).to eq(updated_city_db_path)
expect(manager_instance.metadata.check_at(constants::CITY)).to satisfy { |x| Time.now.to_i - x <= 1 }
end
end
shared_examples "City unchanged" do
it "retains City state" do
allow(manager_instance.current_state(constants::CITY)).to receive(:update!).and_call_original
manager_instance.execute_download_job
manager_instance.current_db_info(constants::CITY).tap do |city_db_info|
expect(city_db_info.path).to eq(existing_city_db_path)
expect(city_db_info).to_not be_pending
expect(city_db_info).to_not be_expired
expect(city_db_info).to_not be_removed
end
expect(manager_instance.current_state(constants::CITY)).to_not have_received(:update!)
end
it "updates City metadata check_at" do
manager_instance.execute_download_job
expect(manager_instance.metadata.database_path(constants::CITY)).to eq(existing_city_db_path)
expect(manager_instance.metadata.check_at(constants::CITY)).to satisfy { |x| Time.now.to_i - x <= 1 }
end
end
shared_examples "City errored" do
it "retains City state" do
allow(manager_instance.current_state(constants::CITY)).to receive(:update!).and_call_original
manager_instance.execute_download_job
manager_instance.current_db_info(constants::CITY).tap do |city_db_info|
expect(city_db_info.path).to eq(existing_city_db_path)
expect(city_db_info).to_not be_pending
expect(city_db_info).to_not be_expired
expect(city_db_info).to_not be_removed
end
expect(manager_instance.current_state(constants::CITY)).to_not have_received(:update!)
end
it "retains City metadata check_at" do
manager_instance.execute_download_job
expect(manager_instance.metadata.database_path(constants::CITY)).to eq(existing_city_db_path)
expect(manager_instance.metadata.check_at(constants::CITY)).to eq(existing_city_db_check_at)
end
end
context "when downloader has updates for all" do
include_context "existing databases from metadata"
let(:updated_city_fetch) { [constants::CITY, true, updated_dirname, updated_city_db_path] }
let(:updated_asn_fetch) { [constants::ASN, true, updated_dirname, updated_asn_db_path] }
let(:downloader_response) do
[
updated_city_fetch,
updated_asn_fetch
]
end
before(:each) do
manager_instance.setup
write_dummy_mmdb(constants::CITY, updated_city_db_path)
write_dummy_mmdb(constants::ASN, updated_asn_db_path)
end
include_examples "City updated"
include_examples "ASN updated"
end
context "when downloader has updates for City, but ASN is unchanged" do
include_context "existing databases from metadata"
let(:updated_city_fetch) { [constants::CITY, true, updated_dirname, updated_city_db_path] }
# implementation detail: the downloader _excludes_ confirmed-same entries from the response
let(:downloader_response) do
[
updated_city_fetch
]
end
before(:each) do
manager_instance.setup
write_dummy_mmdb(constants::CITY, updated_city_db_path)
end
include_examples "City updated"
include_examples "ASN unchanged"
end
context "when downloader has updates for City, but ASN has errors" do
include_context "existing databases from metadata"
let(:updated_city_fetch) { [constants::CITY, true, updated_dirname, updated_city_db_path] }
let(:updated_asn_fetch) { [constants::ASN, false, nil, nil] }
# implementation detail: the downloader _excludes_ confirmed-same entries from the response
let(:downloader_response) do
[
updated_city_fetch,
updated_asn_fetch
]
end
before(:each) do
manager_instance.setup
write_dummy_mmdb(constants::CITY, updated_city_db_path)
end
include_examples "City updated"
include_examples "ASN errored"
include_examples "ASN near expiry warning"
include_examples "ASN past expiry eviction"
end
context "when downloader has no changes" do
include_context "existing databases from metadata"
before(:each) do
manager_instance.setup
end
include_examples "City unchanged"
include_examples "ASN unchanged"
end
context "when downloader is exceptional" do
include_context "existing databases from metadata"
before(:each) do
expect(mock_downloader).to receive(:fetch_databases).with(constants::DB_TYPES).and_raise(RuntimeError)
manager_instance.setup
end
include_examples "City errored"
include_examples "ASN errored"
include_examples "ASN near expiry warning"
include_examples "ASN past expiry eviction"
end
end
end