]> git.ipfire.org Git - thirdparty/kea.git/commitdiff
[#1972] DHCP servers fetch client classes
authorMarcin Siodelski <marcin@isc.org>
Wed, 14 Jul 2021 11:44:07 +0000 (13:44 +0200)
committerMarcin Siodelski <marcin@isc.org>
Wed, 21 Jul 2021 19:58:12 +0000 (21:58 +0200)
Both DHCPv4 and DHCPv6 now fetch client classes from the configuration
backend.

src/lib/dhcpsrv/cb_ctl_dhcp4.cc
src/lib/dhcpsrv/cb_ctl_dhcp6.cc
src/lib/dhcpsrv/client_class_def.cc
src/lib/dhcpsrv/client_class_def.h
src/lib/dhcpsrv/srv_config.cc
src/lib/dhcpsrv/srv_config.h
src/lib/dhcpsrv/tests/cb_ctl_dhcp_unittest.cc
src/lib/dhcpsrv/tests/client_class_def_unittest.cc
src/lib/dhcpsrv/tests/srv_config_unittest.cc

index 31f3051007b07c21fb2c303d8ca07831ba13a779..6b3c36773e7aa1fc5779bb9dbc0e84ad0aa09c3c 100644 (file)
@@ -7,6 +7,7 @@
 #include <config.h>
 #include <dhcpsrv/cb_ctl_dhcp4.h>
 #include <dhcpsrv/cfgmgr.h>
+#include <dhcpsrv/client_class_def.h>
 #include <dhcpsrv/dhcpsrv_log.h>
 #include <dhcpsrv/host_mgr.h>
 #include <dhcpsrv/parsers/simple_parser4.h>
@@ -107,6 +108,12 @@ CBControlDHCPv4::databaseConfigApply(const BackendSelector& backend_selector,
                 cfg->getCfgOption()->del((*entry)->getObjectId());
             }
 
+            range = index.equal_range(boost::make_tuple("dhcp4_client_class",
+                                                        AuditEntry::ModificationType::DELETE));
+            for (auto entry = range.first; entry != range.second; ++entry) {
+                cfg->getClientClassDictionary()->removeClass((*entry)->getObjectId());
+            }
+
             range = index.equal_range(boost::make_tuple("dhcp4_shared_network",
                                                         AuditEntry::ModificationType::DELETE));
             for (auto entry = range.first; entry != range.second; ++entry) {
@@ -189,6 +196,16 @@ CBControlDHCPv4::databaseConfigApply(const BackendSelector& backend_selector,
         }
     }
 
+    // Fetch client classes. They are returned in a ClientClassDictionary.
+    if (!audit_entries.empty()) {
+        updated_entries = fetchConfigElement(audit_entries, "dhcp4_client_class");
+    }
+    if (audit_entries.empty() || !updated_entries.empty()) {
+        ClientClassDictionary client_classes = getMgr().getPool()->getAllClientClasses4(backend_selector,
+                                                                                        server_selector);
+        external_cfg->setClientClassDictionary(boost::make_shared<ClientClassDictionary>(client_classes));
+    }
+
     // Now fetch the shared networks.
     if (!audit_entries.empty()) {
         updated_entries = fetchConfigElement(audit_entries, "dhcp4_shared_network");
index 68af19b264e585e988a860f456d3e3ebb927599c..d2096045f32824540fab41b33c421c7c0f0ca6df 100644 (file)
@@ -107,6 +107,12 @@ CBControlDHCPv6::databaseConfigApply(const db::BackendSelector& backend_selector
                 cfg->getCfgOption()->del((*entry)->getObjectId());
             }
 
+            range = index.equal_range(boost::make_tuple("dhcp6_client_class",
+                                                        AuditEntry::ModificationType::DELETE));
+            for (auto entry = range.first; entry != range.second; ++entry) {
+                cfg->getClientClassDictionary()->removeClass((*entry)->getObjectId());
+            }
+
             range = index.equal_range(boost::make_tuple("dhcp6_shared_network",
                                                         AuditEntry::ModificationType::DELETE));
             for (auto entry = range.first; entry != range.second; ++entry) {
@@ -189,6 +195,16 @@ CBControlDHCPv6::databaseConfigApply(const db::BackendSelector& backend_selector
         }
     }
 
+    // Fetch client classes. They are returned in a ClientClassDictionary.
+    if (!audit_entries.empty()) {
+        updated_entries = fetchConfigElement(audit_entries, "dhcp6_client_class");
+    }
+    if (audit_entries.empty() || !updated_entries.empty()) {
+        ClientClassDictionary client_classes = getMgr().getPool()->getAllClientClasses6(backend_selector,
+                                                                                        server_selector);
+        external_cfg->setClientClassDictionary(boost::make_shared<ClientClassDictionary>(client_classes));
+    }
+
     // Now fetch the shared networks.
     if (!audit_entries.empty()) {
         updated_entries = fetchConfigElement(audit_entries, "dhcp6_shared_network");
index 9b5a500e5465b99160f9689710890102fd3759af..4535e7b21642f19f1979b1307d96ba608c18fd52 100644 (file)
@@ -33,7 +33,6 @@ ClientClassDef::ClientClassDef(const std::string& name,
 
     // We permit an empty expression for now.  This will likely be useful
     // for automatic classes such as vendor class.
-
     // For classes without options, make sure we have an empty collection
     if (!cfg_option_) {
         cfg_option_.reset(new CfgOption());
@@ -297,6 +296,23 @@ ClientClassDictionary::removeClass(const std::string& name) {
     map_->erase(name);
 }
 
+void
+ClientClassDictionary::removeClass(const uint64_t id) {
+    // Class id equal to 0 means it wasn't set.
+    if (id == 0) {
+        return;
+    }
+    for (ClientClassDefList::iterator this_class = list_->begin();
+         this_class != list_->end(); ++this_class) {
+        std::cout << "id: " << id << ", " << "this_class: " << (*this_class)->getId() << std::endl;
+        if ((*this_class)->getId() == id) {
+            map_->erase((*this_class)->getName());
+            list_->erase(this_class);
+            break;
+        }
+    }
+}
+
 const ClientClassDefListPtr&
 ClientClassDictionary::getClasses() const {
     return (list_);
index aab8ddc186443f7c1a0cc9a5c68ac6044d9347b7..8cb87b0d9d4a6d59e6d7be2854c89a012883a278 100644 (file)
@@ -347,6 +347,11 @@ public:
     /// @param name the name of the class to remove
     void removeClass(const std::string& name);
 
+    /// @brief Removes a client class by id.
+    ///
+    /// @param id class id.
+    void removeClass(const uint64_t id);
+
     /// @brief Fetches the dictionary's list of classes
     ///
     /// @return ClientClassDefListPtr to the list of classes
index 9efd1a638b6f50a75063f2955737c5bf507ceebb..e1db1083be783156266cb664d9b8cd92bca8594b 100644 (file)
@@ -20,6 +20,8 @@
 #include <stats/stats_mgr.h>
 #include <util/strutil.h>
 
+#include <boost/make_shared.hpp>
+
 #include <list>
 #include <sstream>
 
@@ -183,6 +185,15 @@ SrvConfig::merge(ConfigBase& other) {
         // Merge options.
         cfg_option_->merge(cfg_option_def_, (*other_srv_config.getCfgOption()));
 
+        if (!other_srv_config.getClientClassDictionary()->empty()) {
+            // Client classes are complicated because they are ordered and may
+            // depend on each other. Merging two lists of classes with preserving
+            // the order would be very involved and could result in errors. Thus,
+            // we simply replace the current list of classes with a new list.
+            setClientClassDictionary(boost::make_shared
+                                     <ClientClassDictionary>(*other_srv_config.getClientClassDictionary()));
+        }
+
         if (CfgMgr::instance().getFamily() == AF_INET) {
             merge4(other_srv_config);
         } else {
index a5778ad8c213edc765b5b52c2b0de454f1878293..b755039626cd2f6d4c3d56c361c9b2323a14534d 100644 (file)
@@ -655,6 +655,13 @@ public:
     /// The data that do not overlap between the two objects is simply
     /// inserted into this configuration.
     ///
+    /// Due to the nature of the client classes, i.e. they are ordered and
+    /// depend on each other, individual classes are not merged. Instead,
+    /// the new list of classes entirely replaces the existing list. It
+    /// implies that client classes should not be defined in a config
+    /// file if there are classes defined in the config backend for this
+    /// server.
+    ///
     /// @warning The call to @c merge may modify the data in the @c other
     /// object. Therefore, the caller must not rely on the data held
     /// in the @c other object after the call to @c merge. Also, the
@@ -669,6 +676,7 @@ public:
     /// - globals
     /// - option definitions
     /// - options
+    /// - client classes
     /// - via @c merge4 or @c merge6 depending on @c CfgMgr::family_:
     ///     - shared networks
     ///     - subnets
index 29d039c30abd2ae0df8c4271bf4d2a7bd8869d10..8bd10b8082de8b9795663769c3419e93ed3205f7 100644 (file)
@@ -12,6 +12,7 @@
 #include <dhcpsrv/cb_ctl_dhcp4.h>
 #include <dhcpsrv/cb_ctl_dhcp6.h>
 #include <dhcpsrv/cfgmgr.h>
+#include <dhcpsrv/client_class_def.h>
 #include <dhcpsrv/host_mgr.h>
 #include <dhcpsrv/testutils/memory_host_data_source.h>
 #include <dhcpsrv/testutils/generic_backend_unittest.h>
@@ -21,6 +22,7 @@
 #include <hooks/callout_manager.h>
 #include <hooks/hooks_manager.h>
 #include <boost/date_time/posix_time/posix_time.hpp>
+#include <boost/make_shared.hpp>
 #include <gtest/gtest.h>
 #include <iostream>
 #include <map>
@@ -288,6 +290,7 @@ public:
         setTimestamp("dhcp4_options", timestamp_index);
         setTimestamp("dhcp4_shared_network", timestamp_index);
         setTimestamp("dhcp4_subnet", timestamp_index);
+        setTimestamp("dhcp4_client_class", timestamp_index);
     }
 
     /// @brief Creates test server configuration and stores it in a test
@@ -375,6 +378,20 @@ public:
         subnet->setModificationTime(getTimestamp("dhcp4_subnet"));
         mgr.getPool()->createUpdateSubnet4(BackendSelector::UNSPEC(), ServerSelector::ALL(),
                                            subnet);
+
+        // Insert client classes into the database.
+        auto expression = boost::make_shared<Expression>();
+        ClientClassDefPtr client_class = boost::make_shared<ClientClassDef>("first-class", expression);
+        client_class->setId(1);
+        client_class->setModificationTime(getTimestamp("dhcp4_client_class"));
+        mgr.getPool()->createUpdateClientClass4(BackendSelector::UNSPEC(), ServerSelector::ALL(),
+                                                client_class, "");
+
+        client_class = boost::make_shared<ClientClassDef>("second-class", expression);
+        client_class->setId(2);
+        client_class->setModificationTime(getTimestamp("dhcp4_client_class"));
+        mgr.getPool()->createUpdateClientClass4(BackendSelector::UNSPEC(), ServerSelector::ALL(),
+                                                client_class, "");
     }
 
     /// @brief Deletes specified global parameter from the configuration
@@ -462,6 +479,24 @@ public:
         addDeleteAuditEntry("dhcp4_subnet", id);
     }
 
+    /// @brief Deletes specified client class from the configuration backend
+    /// and generates audit entry.
+    ///
+    /// @param name Name of the client class to be deleted.
+    void remoteDeleteClientClass(const std::string& name) {
+        auto& mgr = ConfigBackendDHCPv4Mgr::instance();
+
+        auto client_class = mgr.getPool()->getClientClass4(BackendSelector::UNSPEC(),
+                                                           ServerSelector::ALL(),
+                                                           name);
+
+        if (client_class) {
+            mgr.getPool()->deleteClientClass4(BackendSelector::UNSPEC(),
+                                              ServerSelector::ALL(),
+                                              name);
+            addDeleteAuditEntry("dhcp4_client_class", client_class->getId());
+        }
+    }
 
     /// @brief Tests the @c CBControlDHCPv4::databaseConfigApply method.
     ///
@@ -551,6 +586,17 @@ public:
         } else {
             EXPECT_FALSE(found_subnet);
         }
+
+        auto client_classes = srv_cfg->getClientClassDictionary();
+        auto found_class = client_classes->findClass("first-class");
+        if (hasConfigElement("dhcp4_client_class") &&
+            (getTimestamp("dhcp4_client_class") > lb_modification_time)) {
+            ASSERT_TRUE(found_class);
+            EXPECT_EQ("first-class", found_class->getName());
+
+        } else {
+            EXPECT_FALSE(found_class);
+        }
     }
 
     /// @brief Tests deletion of the configuration elements by the
@@ -674,6 +720,19 @@ public:
                 EXPECT_TRUE(found_subnet);
             }
         }
+
+        {
+            SCOPED_TRACE("client classes");
+            // One of the subnets should still be there.
+            EXPECT_TRUE(srv_cfg->getClientClassDictionary()->findClass("second-class"));
+            auto found_client_class = srv_cfg->getClientClassDictionary()->findClass("first-class");
+            if (deleteConfigElement("dhcp4_client_class", 1)) {
+                EXPECT_FALSE(found_client_class);
+
+            } else {
+                EXPECT_TRUE(found_client_class);
+            }
+        }
     }
 
     /// @brief Instance of the @c CBControlDHCPv4 used for testing.
@@ -695,6 +754,8 @@ TEST_F(CBControlDHCPv4Test, databaseConfigApplyAll) {
     addCreateAuditEntry("dhcp4_shared_network", 2);
     addCreateAuditEntry("dhcp4_subnet", 1);
     addCreateAuditEntry("dhcp4_subnet", 2);
+    addCreateAuditEntry("dhcp4_client_class", 1);
+    addCreateAuditEntry("dhcp4_client_class", 2);
 
     testDatabaseConfigApply(getTimestamp(-5));
 }
@@ -709,6 +770,7 @@ TEST_F(CBControlDHCPv4Test, databaseConfigApplyDeleteAll) {
         remoteDeleteOption(DHO_HOST_NAME, DHCP4_OPTION_SPACE);
         remoteDeleteSharedNetwork("one");
         remoteDeleteSubnet(SubnetID(1));
+        remoteDeleteClientClass("first-class");
     });
 }
 
@@ -725,6 +787,7 @@ TEST_F(CBControlDHCPv4Test, databaseConfigApplyDeleteNonExisting) {
         addDeleteAuditEntry("dhcp4_options", 3);
         addDeleteAuditEntry("dhcp4_shared_network", 3);
         addDeleteAuditEntry("dhcp4_subnet", 3);
+        addDeleteAuditEntry("dhcp4_client_class", 3);
     });
 }
 
@@ -860,7 +923,23 @@ TEST_F(CBControlDHCPv4Test, databaseConfigApplySubnetNotFetched) {
     testDatabaseConfigApply(getTimestamp(-3));
 }
 
-// This test verifies that the configuration updates calls the hook.
+// This test verifies that only client classes are merged into the current
+// configuration.
+TEST_F(CBControlDHCPv4Test, databaseConfigApplyClientClasses) {
+    addCreateAuditEntry("dhcp4_client_class", 1);
+    addCreateAuditEntry("dhcp4_client_class", 2);
+    testDatabaseConfigApply(getTimestamp(-5));
+}
+
+// This test verifies that a client class is deleted from the local
+// configuration as a result of being deleted from the database.
+TEST_F(CBControlDHCPv4Test, databaseConfigApplyDeleteClientClass) {
+    testDatabaseConfigApplyDelete(getTimestamp(-5), [this]() {
+        remoteDeleteClientClass("first-class");
+    });
+}
+
+// This test verifies that the configuration update calls the hook.
 TEST_F(CBControlDHCPv4Test, databaseConfigApplyHook) {
 
     // Initialize Hooks Manager.
@@ -1004,6 +1083,7 @@ public:
         setTimestamp("dhcp6_options", timestamp_index);
         setTimestamp("dhcp6_shared_network", timestamp_index);
         setTimestamp("dhcp6_subnet", timestamp_index);
+        setTimestamp("dhcp6_client_class", timestamp_index);
     }
 
     /// @brief Creates test server configuration and stores it in a test
@@ -1091,6 +1171,20 @@ public:
         subnet->setModificationTime(getTimestamp("dhcp6_subnet"));
         mgr.getPool()->createUpdateSubnet6(BackendSelector::UNSPEC(), ServerSelector::ALL(),
                                            subnet);
+
+        // Insert client classes into the database.
+        auto expression = boost::make_shared<Expression>();
+        ClientClassDefPtr client_class = boost::make_shared<ClientClassDef>("first-class", expression);
+        client_class->setId(1);
+        client_class->setModificationTime(getTimestamp("dhcp6_client_class"));
+        mgr.getPool()->createUpdateClientClass6(BackendSelector::UNSPEC(), ServerSelector::ALL(),
+                                                client_class, "");
+
+        client_class = boost::make_shared<ClientClassDef>("second-class", expression);
+        client_class->setId(2);
+        client_class->setModificationTime(getTimestamp("dhcp6_client_class"));
+        mgr.getPool()->createUpdateClientClass6(BackendSelector::UNSPEC(), ServerSelector::ALL(),
+                                                client_class, "");
     }
 
     /// @brief Deletes specified global parameter from the configuration
@@ -1178,6 +1272,24 @@ public:
         addDeleteAuditEntry("dhcp6_subnet", id);
     }
 
+    /// @brief Deletes specified client class from the configuration backend
+    /// and generates audit entry.
+    ///
+    /// @param name Name of the client class to be deleted.
+    void remoteDeleteClientClass(const std::string& name) {
+        auto& mgr = ConfigBackendDHCPv6Mgr::instance();
+
+        auto client_class = mgr.getPool()->getClientClass6(BackendSelector::UNSPEC(),
+                                                           ServerSelector::ALL(),
+                                                           name);
+
+        if (client_class) {
+            mgr.getPool()->deleteClientClass6(BackendSelector::UNSPEC(),
+                                              ServerSelector::ALL(),
+                                              name);
+            addDeleteAuditEntry("dhcp6_client_class", client_class->getId());
+        }
+    }
 
     /// @brief Tests the @c CBControlDHCPv6::databaseConfigApply method.
     ///
@@ -1267,6 +1379,17 @@ public:
         } else {
             EXPECT_FALSE(found_subnet);
         }
+
+        auto client_classes = srv_cfg->getClientClassDictionary();
+        auto found_class = client_classes->findClass("first-class");
+        if (hasConfigElement("dhcp6_client_class") &&
+            (getTimestamp("dhcp6_client_class") > lb_modification_time)) {
+            ASSERT_TRUE(found_class);
+            EXPECT_EQ("first-class", found_class->getName());
+
+        } else {
+            EXPECT_FALSE(found_class);
+        }
     }
 
     /// @brief Tests deletion of the configuration elements by the
@@ -1410,6 +1533,8 @@ TEST_F(CBControlDHCPv6Test, databaseConfigApplyAll) {
     addCreateAuditEntry("dhcp6_shared_network", 2);
     addCreateAuditEntry("dhcp6_subnet", 1);
     addCreateAuditEntry("dhcp6_subnet", 2);
+    addCreateAuditEntry("dhcp6_client_class", 1);
+    addCreateAuditEntry("dhcp6_client_class", 2);
 
     testDatabaseConfigApply(getTimestamp(-5));
 }
@@ -1575,7 +1700,23 @@ TEST_F(CBControlDHCPv6Test, databaseConfigApplySubnetNotFetched) {
     testDatabaseConfigApply(getTimestamp(-3));
 }
 
-// This test verifies that the configuration updates calls the hook.
+// This test verifies that only client classes are merged into the current
+// configuration.
+TEST_F(CBControlDHCPv6Test, databaseConfigApplyClientClasses) {
+    addCreateAuditEntry("dhcp6_client_class", 1);
+    addCreateAuditEntry("dhcp6_client_class", 2);
+    testDatabaseConfigApply(getTimestamp(-5));
+}
+
+// This test verifies that a client class is deleted from the local
+// configuration as a result of being deleted from the database.
+TEST_F(CBControlDHCPv6Test, databaseConfigApplyDeleteClientClass) {
+    testDatabaseConfigApplyDelete(getTimestamp(-5), [this]() {
+        remoteDeleteClientClass("first-class");
+    });
+}
+
+// This test verifies that the configuration update calls the hook.
 TEST_F(CBControlDHCPv6Test, databaseConfigApplyHook) {
 
     // Initialize Hooks Manager.
index d33f01b8813c12acf99786f8f75ad263ad6b998f..8bd6be722cf44b0f2ad51abc4152f9ea4215179a 100644 (file)
@@ -22,6 +22,7 @@
 /// classes.
 
 using namespace std;
+using namespace isc::data;
 using namespace isc::dhcp;
 using namespace isc::util;
 using namespace isc::asiolink;
@@ -354,18 +355,26 @@ TEST(ClientClassDictionary, basics) {
     EXPECT_EQ(3, classes->size());
     EXPECT_FALSE(classes->empty());
 
+    // Removing client class by id of 0 should be no-op.
+    ASSERT_NO_THROW(dictionary->removeClass(0));
+    EXPECT_EQ(3, classes->size());
+    EXPECT_FALSE(classes->empty());
+
     // Verify we can find them all.
     ASSERT_NO_THROW(cclass = dictionary->findClass("cc1"));
     ASSERT_TRUE(cclass);
     EXPECT_EQ("cc1", cclass->getName());
+    cclass->setId(1);
 
     ASSERT_NO_THROW(cclass = dictionary->findClass("cc2"));
     ASSERT_TRUE(cclass);
     EXPECT_EQ("cc2", cclass->getName());
+    cclass->setId(2);
 
     ASSERT_NO_THROW(cclass = dictionary->findClass("cc3"));
     ASSERT_TRUE(cclass);
     EXPECT_EQ("cc3", cclass->getName());
+    cclass->setId(3);
 
     // Verify the looking for non-existing returns empty pointer
     ASSERT_NO_THROW(cclass = dictionary->findClass("bogus"));
@@ -385,6 +394,13 @@ TEST(ClientClassDictionary, basics) {
     ASSERT_NO_THROW(dictionary->removeClass("cc3"));
     EXPECT_EQ(2, classes->size());
     EXPECT_FALSE(classes->empty());
+
+    // Verify that we can remove client class by id.
+    ASSERT_NO_THROW(dictionary->removeClass(2));
+    EXPECT_EQ(1, classes->size());
+    EXPECT_FALSE(classes->empty());
+    ASSERT_NO_THROW(cclass = dictionary->findClass("cc2"));
+    EXPECT_FALSE(cclass);
 }
 
 // Verifies copy constructor and equality tools (methods/operators)
index 915bb50540f5e7ef0505cdf929875f425b854d7d..b4cac7d43d2440b28c8112491725081a50a75e4a 100644 (file)
@@ -8,6 +8,7 @@
 
 #include <dhcp/tests/iface_mgr_test_config.h>
 #include <dhcpsrv/cfgmgr.h>
+#include <dhcpsrv/client_class_def.h>
 #include <dhcpsrv/srv_config.h>
 #include <dhcpsrv/subnet.h>
 #include <process/logging_info.h>
@@ -1148,6 +1149,59 @@ TEST_F(SrvConfigTest, mergeGlobals6) {
 
 }
 
+// This test verifies that new list of client classes replaces and old list
+// when server configuration is merged.
+TEST_F(SrvConfigTest, mergeClientClasses) {
+    // Let's create the "existing" config we will merge into.
+    SrvConfig cfg_to;
+
+    auto expression = boost::make_shared<Expression>();
+    auto client_class = boost::make_shared<ClientClassDef>("foo", expression);
+    cfg_to.getClientClassDictionary()->addClass(client_class);
+
+    client_class = boost::make_shared<ClientClassDef>("bar", expression);
+    cfg_to.getClientClassDictionary()->addClass(client_class);
+
+    // Now we'll create the config we'll merge from.
+    SrvConfig cfg_from;
+    client_class = boost::make_shared<ClientClassDef>("baz", expression);
+    cfg_from.getClientClassDictionary()->addClass(client_class);
+
+    client_class = boost::make_shared<ClientClassDef>("abc", expression);
+    cfg_from.getClientClassDictionary()->addClass(client_class);
+
+    ASSERT_NO_THROW(cfg_to.merge(cfg_from));
+
+    // The old classes should be replaced with new classes.
+    EXPECT_FALSE(cfg_to.getClientClassDictionary()->findClass("foo"));
+    EXPECT_FALSE(cfg_to.getClientClassDictionary()->findClass("bar"));
+    EXPECT_TRUE(cfg_to.getClientClassDictionary()->findClass("baz"));
+    EXPECT_TRUE(cfg_to.getClientClassDictionary()->findClass("abc"));
+}
+
+// This test verifies that client classes are not modified if the merged
+// list of classes is empty.
+TEST_F(SrvConfigTest, mergeEmptyClientClasses) {
+    // Let's create the "existing" config we will merge into.
+    SrvConfig cfg_to;
+
+    auto expression = boost::make_shared<Expression>();
+    auto client_class = boost::make_shared<ClientClassDef>("foo", expression);
+    cfg_to.getClientClassDictionary()->addClass(client_class);
+
+    client_class = boost::make_shared<ClientClassDef>("bar", expression);
+    cfg_to.getClientClassDictionary()->addClass(client_class);
+
+    // Now we'll create the config we'll merge from.
+    SrvConfig cfg_from;
+
+    ASSERT_NO_THROW(cfg_to.merge(cfg_from));
+
+    // Empty list of classes should not replace an existing list.
+    EXPECT_TRUE(cfg_to.getClientClassDictionary()->findClass("foo"));
+    EXPECT_TRUE(cfg_to.getClientClassDictionary()->findClass("bar"));
+}
+
 // Validates SrvConfig::moveDdnsParams by ensuring that deprecated dhcp-ddns
 // parameters are:
 // 1. Translated to their global counterparts if they do not exist globally