Просмотр исходного кода

Adding a test for MaxMind support (#107000)

Keith Massey 1 год назад
Родитель
Сommit
bbeee3451b

+ 502 - 0
modules/ingest-geoip/src/test/java/org/elasticsearch/ingest/geoip/MaxMindSupportTests.java

@@ -0,0 +1,502 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.ingest.geoip;
+
+import com.maxmind.db.Network;
+import com.maxmind.geoip2.DatabaseReader;
+import com.maxmind.geoip2.model.AbstractResponse;
+import com.maxmind.geoip2.model.AnonymousIpResponse;
+import com.maxmind.geoip2.model.AsnResponse;
+import com.maxmind.geoip2.model.CityResponse;
+import com.maxmind.geoip2.model.ConnectionTypeResponse;
+import com.maxmind.geoip2.model.CountryResponse;
+import com.maxmind.geoip2.model.DomainResponse;
+import com.maxmind.geoip2.model.EnterpriseResponse;
+import com.maxmind.geoip2.model.IpRiskResponse;
+import com.maxmind.geoip2.model.IspResponse;
+import com.maxmind.geoip2.record.MaxMind;
+
+import org.elasticsearch.common.util.set.Sets;
+import org.elasticsearch.test.ESTestCase;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.net.InetAddress;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * This test is meant to help us understand if we are falling behind on support for mmdb types and fields that get added over time. It
+ * maintains a list for each mmdb file type we support of which fields in that type we support and which we do not.
+ *
+ * This test will:
+ *
+ * - Fail if MaxMind adds a new field to one of the mmdb file types we currently support (city, country, etc) and we don't update this test
+ *   with whether or not we support that field.
+ * - Fail if we add support for a new mmdb file type (enterprise for example) but don't update the test with which fields we do and do not
+ *   support.
+ * - Fail if MaxMind adds a new mmdb file type that we don't know about
+ * - Fail if we expose a MaxMind type through GeoIpDatabase, but don't update the test to know how to handle it
+ */
+public class MaxMindSupportTests extends ESTestCase {
+
+    private static final Set<String> ASN_SUPPORTED_FIELDS = Set.of("autonomousSystemNumber", "autonomousSystemOrganization", "network");
+    private static final Set<String> ASN_UNSUPPORTED_FIELDS = Set.of("ipAddress");
+
+    private static final Set<String> CITY_SUPPORTED_FIELDS = Set.of(
+        "city.name",
+        "continent.name",
+        "country.isoCode",
+        "country.name",
+        "location.latitude",
+        "location.longitude",
+        "location.timeZone",
+        "mostSpecificSubdivision.isoCode",
+        "mostSpecificSubdivision.name"
+    );
+    private static final Set<String> CITY_UNSUPPORTED_FIELDS = Set.of(
+        "city.confidence",
+        "city.geoNameId",
+        "city.names",
+        "continent.code",
+        "continent.geoNameId",
+        "continent.names",
+        "country.confidence",
+        "country.geoNameId",
+        "country.inEuropeanUnion",
+        "country.names",
+        "leastSpecificSubdivision.confidence",
+        "leastSpecificSubdivision.geoNameId",
+        "leastSpecificSubdivision.isoCode",
+        "leastSpecificSubdivision.name",
+        "leastSpecificSubdivision.names",
+        "location.accuracyRadius",
+        "location.averageIncome",
+        "location.metroCode",
+        "location.populationDensity",
+        "maxMind",
+        "mostSpecificSubdivision.confidence",
+        "mostSpecificSubdivision.geoNameId",
+        "mostSpecificSubdivision.names",
+        "postal.code",
+        "postal.confidence",
+        "registeredCountry.confidence",
+        "registeredCountry.geoNameId",
+        "registeredCountry.inEuropeanUnion",
+        "registeredCountry.isoCode",
+        "registeredCountry.name",
+        "registeredCountry.names",
+        "representedCountry.confidence",
+        "representedCountry.geoNameId",
+        "representedCountry.inEuropeanUnion",
+        "representedCountry.isoCode",
+        "representedCountry.name",
+        "representedCountry.names",
+        "representedCountry.type",
+        "subdivisions.confidence",
+        "subdivisions.geoNameId",
+        "subdivisions.isoCode",
+        "subdivisions.name",
+        "subdivisions.names",
+        "traits.anonymous",
+        "traits.anonymousProxy",
+        "traits.anonymousVpn",
+        "traits.anycast",
+        "traits.autonomousSystemNumber",
+        "traits.autonomousSystemOrganization",
+        "traits.connectionType",
+        "traits.domain",
+        "traits.hostingProvider",
+        "traits.ipAddress",
+        "traits.isp",
+        "traits.legitimateProxy",
+        "traits.mobileCountryCode",
+        "traits.mobileNetworkCode",
+        "traits.network",
+        "traits.organization",
+        "traits.publicProxy",
+        "traits.residentialProxy",
+        "traits.satelliteProvider",
+        "traits.staticIpScore",
+        "traits.torExitNode",
+        "traits.userCount",
+        "traits.userType"
+    );
+
+    private static final Set<String> COUNTRY_SUPPORTED_FIELDS = Set.of("continent.name", "country.isoCode", "country.name");
+    private static final Set<String> COUNTRY_UNSUPPORTED_FIELDS = Set.of(
+        "continent.code",
+        "continent.geoNameId",
+        "continent.names",
+        "country.confidence",
+        "country.geoNameId",
+        "country.inEuropeanUnion",
+        "country.names",
+        "maxMind",
+        "registeredCountry.confidence",
+        "registeredCountry.geoNameId",
+        "registeredCountry.inEuropeanUnion",
+        "registeredCountry.isoCode",
+        "registeredCountry.name",
+        "registeredCountry.names",
+        "representedCountry.confidence",
+        "representedCountry.geoNameId",
+        "representedCountry.inEuropeanUnion",
+        "representedCountry.isoCode",
+        "representedCountry.name",
+        "representedCountry.names",
+        "representedCountry.type",
+        "traits.anonymous",
+        "traits.anonymousProxy",
+        "traits.anonymousVpn",
+        "traits.anycast",
+        "traits.autonomousSystemNumber",
+        "traits.autonomousSystemOrganization",
+        "traits.connectionType",
+        "traits.domain",
+        "traits.hostingProvider",
+        "traits.ipAddress",
+        "traits.isp",
+        "traits.legitimateProxy",
+        "traits.mobileCountryCode",
+        "traits.mobileNetworkCode",
+        "traits.network",
+        "traits.organization",
+        "traits.publicProxy",
+        "traits.residentialProxy",
+        "traits.satelliteProvider",
+        "traits.staticIpScore",
+        "traits.torExitNode",
+        "traits.userCount",
+        "traits.userType"
+    );
+
+    private static final Map<Database, Set<String>> TYPE_TO_SUPPORTED_FIELDS_MAP = Map.of(
+        Database.Asn,
+        ASN_SUPPORTED_FIELDS,
+        Database.City,
+        CITY_SUPPORTED_FIELDS,
+        Database.Country,
+        COUNTRY_SUPPORTED_FIELDS
+    );
+    private static final Map<Database, Set<String>> TYPE_TO_UNSUPPORTED_FIELDS_MAP = Map.of(
+        Database.Asn,
+        ASN_UNSUPPORTED_FIELDS,
+        Database.City,
+        CITY_UNSUPPORTED_FIELDS,
+        Database.Country,
+        COUNTRY_UNSUPPORTED_FIELDS
+    );
+    private static final Map<Database, Class<? extends AbstractResponse>> TYPE_TO_MAX_MIND_CLASS = Map.of(
+        Database.Asn,
+        AsnResponse.class,
+        Database.City,
+        CityResponse.class,
+        Database.Country,
+        CountryResponse.class
+    );
+
+    private static final Set<Class<? extends AbstractResponse>> KNOWN_UNSUPPORTED_RESPONSE_CLASSES = Set.of(
+        AnonymousIpResponse.class,
+        ConnectionTypeResponse.class,
+        DomainResponse.class,
+        EnterpriseResponse.class,
+        IspResponse.class,
+        IpRiskResponse.class
+    );
+
+    public void testMaxMindSupport() {
+        for (Database databaseType : Database.values()) {
+            Class<? extends AbstractResponse> maxMindClass = TYPE_TO_MAX_MIND_CLASS.get(databaseType);
+            Set<String> supportedFields = TYPE_TO_SUPPORTED_FIELDS_MAP.get(databaseType);
+            Set<String> unsupportedFields = TYPE_TO_UNSUPPORTED_FIELDS_MAP.get(databaseType);
+            assertNotNull(
+                "A new Database type, "
+                    + databaseType
+                    + ", has been added, but this test has not been updated to know which MaxMind "
+                    + "class to use to load it. Update TYPE_TO_MAX_MIND_CLASS",
+                maxMindClass
+            );
+            assertNotNull(
+                "A new Database type, "
+                    + databaseType
+                    + ", has been added, but this test has not been updated to know which fields we "
+                    + "support for it. Update TYPE_TO_SUPPORTED_FIELDS_MAP",
+                supportedFields
+            );
+            assertNotNull(
+                "A new Database type, "
+                    + databaseType
+                    + ", has been added, but this test has not been updated to know which fields we "
+                    + "do not support for it. Update TYPE_TO_UNSUPPORTED_FIELDS_MAP",
+                unsupportedFields
+            );
+            /*
+             * fieldNamesFromMaxMind:        The fields that MaxMind's Response object contains
+             * unusedFields:                 Fields that MaxMind's Response object contains that we do not claim to support
+             * unknownUnsupportedFieldNames: Fields that we document as unsupported that MaxMind's Response object doesn't even contain
+             * unknownSupportedFieldNames:   Fields that we document as supported that MaxMind's Response object doesn't even contain
+             */
+            final SortedSet<String> fieldNamesFromMaxMind = getFieldNamesUsedFromClass("", maxMindClass);
+            Set<String> unusedFields = Sets.difference(fieldNamesFromMaxMind, supportedFields);
+            Set<String> unknownUnsupportedFieldNames = Sets.difference(unsupportedFields, unusedFields);
+            assertThat(
+                "We have documented fields in TYPE_TO_UNSUPPORTED_FIELDS_MAP that MaxMind does not actually support for "
+                    + databaseType
+                    + ": "
+                    + unknownUnsupportedFieldNames,
+                unknownUnsupportedFieldNames,
+                empty()
+            );
+            assertThat(
+                "New MaxMind fields have been added for "
+                    + databaseType
+                    + " that we do not use or have documented in TYPE_TO_UNSUPPORTED_FIELDS_MAP. The actual list of fields is:\n"
+                    + getFormattedList(unusedFields),
+                unusedFields,
+                equalTo(new TreeSet<>(unsupportedFields))
+            );
+            Set<String> unknownSupportedFieldNames = Sets.difference(supportedFields, fieldNamesFromMaxMind);
+            assertThat(
+                "We are attempting to use fields that MaxMind does not support for " + databaseType + ": " + unknownSupportedFieldNames,
+                unknownSupportedFieldNames,
+                empty()
+            );
+        }
+    }
+
+    public void testUnknownMaxMindResponseClassess() {
+        Set<Class<? extends AbstractResponse>> supportedMaxMindClasses = new HashSet<>(TYPE_TO_MAX_MIND_CLASS.values());
+        // First just a sanity check that there's no overlap between what's supported and what's not:
+        Set<Class<? extends AbstractResponse>> supportedAndUnsupportedMaxMindClasses = Sets.intersection(
+            supportedMaxMindClasses,
+            KNOWN_UNSUPPORTED_RESPONSE_CLASSES
+        );
+        assertThat(
+            "We claim both to support and not support some MaxMind response classes: " + supportedAndUnsupportedMaxMindClasses,
+            supportedAndUnsupportedMaxMindClasses,
+            empty()
+        );
+        Set<Class<? extends AbstractResponse>> allActualMaxMindClasses = new HashSet<>();
+
+        Method[] methods = DatabaseReader.class.getMethods();
+        for (Method method : methods) {
+            if (method.getName().startsWith("try")) {
+                if (method.getReturnType().equals(Optional.class)) {
+                    Type genericReturnType = method.getGenericReturnType();
+                    if (genericReturnType instanceof ParameterizedType parameterizedGenericReturnType) {
+                        Type[] actualTypes = parameterizedGenericReturnType.getActualTypeArguments();
+                        if (actualTypes != null && actualTypes.length == 1 && actualTypes[0] instanceof Class<?> actualTypeClass) {
+                            allActualMaxMindClasses.add(actualTypeClass.asSubclass(AbstractResponse.class));
+                            if (KNOWN_UNSUPPORTED_RESPONSE_CLASSES.contains(actualTypeClass) == false) {
+                                assertTrue(
+                                    "MaxMind has added support for " + actualTypeClass.getSimpleName(),
+                                    supportedMaxMindClasses.contains(actualTypeClass)
+                                );
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        // Now make sure that we're not claiming to support any maxmind classes that aren't ever read by DatabaseReader:
+        Set<Class<? extends AbstractResponse>> supportedMaxMindClassesThatDoNotExist = Sets.difference(
+            supportedMaxMindClasses,
+            allActualMaxMindClasses
+        );
+        assertThat(
+            "We claim to support a MaxMind response class that MaxMind does not expose through DatabaseReader: "
+                + supportedMaxMindClassesThatDoNotExist,
+            supportedMaxMindClassesThatDoNotExist,
+            empty()
+        );
+    }
+
+    /*
+     * This tests that this test has a mapping in TYPE_TO_MAX_MIND_CLASS for all MaxMind classes exposed through GeoIpDatabase.
+     */
+    public void testUsedMaxMindResponseClassesAreAccountedFor() {
+        Set<Class<? extends AbstractResponse>> usedMaxMindResponseClasses = getUsedMaxMindResponseClasses();
+        Set<Class<? extends AbstractResponse>> supportedMaxMindClasses = new HashSet<>(TYPE_TO_MAX_MIND_CLASS.values());
+        Set<Class<? extends AbstractResponse>> usedButNotSupportedMaxMindResponseClasses = Sets.difference(
+            usedMaxMindResponseClasses,
+            supportedMaxMindClasses
+        );
+        assertThat(
+            "GeoIpDatabase exposes MaxMind response classes that this test does not know what to do with. Add mappings to "
+                + "TYPE_TO_MAX_MIND_CLASS for the following: "
+                + usedButNotSupportedMaxMindResponseClasses,
+            usedButNotSupportedMaxMindResponseClasses,
+            empty()
+        );
+        Set<Class<? extends AbstractResponse>> supportedButNotUsedMaxMindClasses = Sets.difference(
+            supportedMaxMindClasses,
+            usedMaxMindResponseClasses
+        );
+        assertThat(
+            "This test claims to support MaxMind response classes that are not exposed in GeoIpDatabase. Remove the following from "
+                + "TYPE_TO_MAX_MIND_CLASS: "
+                + supportedButNotUsedMaxMindClasses,
+            supportedButNotUsedMaxMindClasses,
+            empty()
+        );
+    }
+
+    /*
+     * This is the list of field types that causes us to stop recursing. That is, fields of these types are the lowest-level fields that
+     * we care about.
+     */
+    private static final Set<Class<?>> TERMINAL_TYPES = Set.of(
+        boolean.class,
+        Boolean.class,
+        char.class,
+        Character.class,
+        Class.class,
+        ConnectionTypeResponse.ConnectionType.class,
+        double.class,
+        Double.class,
+        InetAddress.class,
+        int.class,
+        Integer.class,
+        long.class,
+        Long.class,
+        MaxMind.class,
+        Network.class,
+        Object.class,
+        String.class,
+        void.class,
+        Void.class
+    );
+    /*
+     * These are types that are containers for other types. We don't need to recurse into each method on these types. Instead, we need to
+     *  look at their generic types.
+     */
+    private static final Set<Class<?>> CONTAINER_TYPES = Set.of(Collection.class, List.class, Map.class, Optional.class);
+    /*
+     * These are methods we don't want to traverse into.
+     */
+    private static final Set<Method> IGNORED_METHODS = Arrays.stream(Object.class.getMethods()).collect(Collectors.toUnmodifiableSet());
+
+    /**
+     * Returns the set of bean-property-like field names referenced from aClass, sorted alphabetically. This method calls itself
+     * recursively for all methods until it reaches one of the types in TERMINAL_TYPES. The name of the method returning one of those
+     * terminal types is converted to a bean-property-like name using the "beanify" method.
+     *
+     * @param context This is a String representing where in the list of methods we are
+     * @param aClass The class whose methods we want to traverse to generate field names
+     * @return A sorted set of bean-property-like field names that can recursively be found from aClass
+     */
+    private static SortedSet<String> getFieldNamesUsedFromClass(String context, Class<?> aClass) {
+        SortedSet<String> fieldNames = new TreeSet<>();
+        Method[] methods = aClass.getMethods();
+        if (TERMINAL_TYPES.contains(aClass)) {
+            // We got here because a container type had a terminal type
+            fieldNames.add(context);
+            return fieldNames;
+        }
+        for (Method method : methods) {
+            if (IGNORED_METHODS.contains(method)) {
+                continue;
+            }
+            if (method.getName().startsWith("to")) {
+                // ignoring methods like toJson or toString
+                continue;
+            }
+            String currentContext = context + (context.isEmpty() ? "" : ".") + beanify(method.getName());
+            if (TERMINAL_TYPES.contains(method.getReturnType())) {
+                fieldNames.add(currentContext);
+            } else {
+                Class<?> returnType = method.getReturnType();
+                if (CONTAINER_TYPES.contains(returnType)) {
+                    ParameterizedType genericReturnType = (ParameterizedType) method.getGenericReturnType();
+                    for (Type actualType : genericReturnType.getActualTypeArguments()) {
+                        if (actualType instanceof Class<?> actualTypeClass) {
+                            fieldNames.addAll(getFieldNamesUsedFromClass(currentContext, actualTypeClass));
+                        } else {
+                            assert false : "This test needs to be updated to deal with this situation";
+                        }
+                    }
+                } else {
+                    fieldNames.addAll(getFieldNamesUsedFromClass(currentContext, method.getReturnType()));
+                }
+            }
+        }
+        return fieldNames;
+    }
+
+    /*
+     * This method converts a method name into what would be its equivalent bean property. For example "getName" returns "name".
+     */
+    private static String beanify(String methodName) {
+        if (methodName.startsWith("get")) {
+            return beanify("get", methodName);
+        } else if (methodName.startsWith("is")) {
+            return beanify("is", methodName);
+        } else {
+            return methodName;
+        }
+    }
+
+    private static String beanify(String prefix, String methodName) {
+        return methodName.substring(prefix.length(), prefix.length() + 1).toLowerCase(Locale.ROOT) + methodName.substring(
+            prefix.length() + 1
+        );
+    }
+
+    /*
+     * This is a convenience to format the list of field names in fields into a String that can be copied into a Set initializer above,
+     * like countryUnsupportedFields.
+     */
+    private static String getFormattedList(Set<String> fields) {
+        StringBuilder result = new StringBuilder();
+        SortedSet<String> sortedFields = new TreeSet<>(fields);
+        for (Iterator<String> it = sortedFields.iterator(); it.hasNext();) {
+            result.append("\"");
+            result.append(it.next());
+            result.append("\"");
+            if (it.hasNext()) {
+                result.append(",\n");
+            }
+        }
+        return result.toString();
+    }
+
+    /*
+     * This returns all AbstractResponse classes that are returned from getter methods on GeoIpDatabase.
+     */
+    private static Set<Class<? extends AbstractResponse>> getUsedMaxMindResponseClasses() {
+        Set<Class<? extends AbstractResponse>> result = new HashSet<>();
+        Method[] methods = GeoIpDatabase.class.getMethods();
+        for (Method method : methods) {
+            if (method.getName().startsWith("get")) {
+                Class<?> returnType = method.getReturnType();
+                try {
+                    result.add(returnType.asSubclass(AbstractResponse.class));
+                } catch (ClassCastException ignore) {
+                    // This is not what we were looking for, move on
+                }
+            }
+        }
+        return result;
+    }
+}