Repository: kroxylicious/kroxylicious
Pull request #565 (2023-08-17 00:26:09)

Move ConfigParser unit tests to live with ConfigParser
### Type of change

- Enhancement / new feature

### Description

Starting to chip at the coverage. Adding tests for `AbstractDefinitionBuilder`, adding `ConfigParser` tests and moved some ConfigParser tests from kroxylicious-test-tools to kroxylicious so the coverage is visible to sonar.

### Checklist

_Please go through this checklist and make sure all applicable tasks have been done_

- [ ] Write tests
- [ ] Make sure all tests pass
- [ ] Review performance test results. Ensure that any degradations to performance numbers are understood and justified.
- [ ] Make sure all Sonar-Lint warnings are addressed or are justifiably ignored.
- [ ] Update documentation
- [ ] Reference relevant issue(s) and close them after merging
- [ ] For user facing changes, update CHANGELOG.md (remember to include changes affecting the API of the test artefacts too).


robobario: > LGTM.
> 
> Out of curiosity why was `ConfigParserTest` coverage not visible to Sonar? Just that it was executed in one module but used in another?

yeah I guess because `ConfigParser` is in a different module from `ConfigurationTest` it's not included in the coverage measure. See in [main](https://sonarcloud.io/component_measures?metric=coverage&selected=kroxylicious_kroxylicious%3Akroxylicious%2Fsrc%2Fmain%2Fjava%2Fio%2Fkroxylicious%2Fproxy%2Fconfig%2FConfigParser.java&view=list&id=kroxylicious_kroxylicious) it thinks parseConfiguration and toYaml aren't covered despite them being used.
sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=kroxylicious_kroxylicious&pullRequest=565)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=CODE_SMELL) [2 Code Smells](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=CODE_SMELL)

[![54.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/50-16px.png '54.2%')](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=565&metric=new_coverage&view=list) [54.2% Coverage](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=565&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=565&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=565&metric=new_duplicated_lines_density&view=list)



![idea](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/light_bulb-16px.png 'idea') Catch issues before they fail your Quality Gate with our IDE extension ![sonarlint](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/sonarlint-16px.png 'sonarlint') [SonarLint](https://www.sonarsource.com/products/sonarlint/features/connected-mode/?referrer=sonarcloud-welcome)
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=kroxylicious_kroxylicious&pullRequest=565)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=CODE_SMELL) [2 Code Smells](https://sonarcloud.io/project/issues?id=kroxylicious_kroxylicious&pullRequest=565&resolved=false&types=CODE_SMELL)

[![100.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/100-16px.png '100.0%')](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=565&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=565&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=565&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=kroxylicious_kroxylicious&pullRequest=565&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/kroxylicious/src/main/java/io/kroxylicious/proxy/config/AbstractDefinitionBuilder.java b/kroxylicious/src/main/java/io/kroxylicious/proxy/config/AbstractDefinitionBuilder.java
index beeab754..6d7d6b5d 100644
--- a/kroxylicious/src/main/java/io/kroxylicious/proxy/config/AbstractDefinitionBuilder.java
+++ b/kroxylicious/src/main/java/io/kroxylicious/proxy/config/AbstractDefinitionBuilder.java
@@ -18,7 +18,7 @@ public abstract class AbstractDefinitionBuilder<D> {
     private final String type;
     private Map<String, Object> config;
 
-    public AbstractDefinitionBuilder(String type) {
+    protected AbstractDefinitionBuilder(String type) {
         Objects.requireNonNull(type);
         this.type = type;
     }
@@ -44,6 +44,7 @@ public abstract class AbstractDefinitionBuilder<D> {
         return withConfig(Map.of(k1, v1, k2, v2, k3, v3));
     }
 
+    @SuppressWarnings("java:S107") // Methods should not have too many parameters - ignored as this convenience shouldn't blow any minds
     public AbstractDefinitionBuilder<D> withConfig(String k1, Object v1, String k2, Object v2, String k3, Object v3, String k4, Object v4) {
         return withConfig(Map.of(k1, v1, k2, v2, k3, v3, k4, v4));
     }
diff --git a/kroxylicious/src/main/java/io/kroxylicious/proxy/config/admin/PrometheusMetricsConfig.java b/kroxylicious/src/main/java/io/kroxylicious/proxy/config/admin/PrometheusMetricsConfig.java
index 0c146222..c3e31267 100644
--- a/kroxylicious/src/main/java/io/kroxylicious/proxy/config/admin/PrometheusMetricsConfig.java
+++ b/kroxylicious/src/main/java/io/kroxylicious/proxy/config/admin/PrometheusMetricsConfig.java
@@ -5,6 +5,9 @@
  */
 package io.kroxylicious.proxy.config.admin;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+@JsonSerialize
 public class PrometheusMetricsConfig {
 
     public PrometheusMetricsConfig() {
diff --git a/kroxylicious/src/main/java/io/kroxylicious/proxy/internal/clusternetworkaddressconfigprovider/PortPerBrokerClusterNetworkAddressConfigProvider.java b/kroxylicious/src/main/java/io/kroxylicious/proxy/internal/clusternetworkaddressconfigprovider/PortPerBrokerClusterNetworkAddressConfigProvider.java
index 73a82301..262570f3 100644
--- a/kroxylicious/src/main/java/io/kroxylicious/proxy/internal/clusternetworkaddressconfigprovider/PortPerBrokerClusterNetworkAddressConfigProvider.java
+++ b/kroxylicious/src/main/java/io/kroxylicious/proxy/internal/clusternetworkaddressconfigprovider/PortPerBrokerClusterNetworkAddressConfigProvider.java
@@ -145,6 +145,5 @@ public class PortPerBrokerClusterNetworkAddressConfigProvider implements Cluster
         public HostPort getBootstrapAddress() {
             return bootstrapAddress;
         }
-
     }
 }


Test patch:
diff --git a/kroxylicious-test-tools/src/test/java/io/kroxylicious/proxy/config/ConfigurationTest.java b/kroxylicious-test-tools/src/test/java/io/kroxylicious/proxy/config/ConfigurationTest.java
index 421828d2..66e5f00f 100644
--- a/kroxylicious-test-tools/src/test/java/io/kroxylicious/proxy/config/ConfigurationTest.java
+++ b/kroxylicious-test-tools/src/test/java/io/kroxylicious/proxy/config/ConfigurationTest.java
@@ -6,15 +6,12 @@
 
 package io.kroxylicious.proxy.config;
 
-import java.util.List;
 import java.util.stream.Stream;
 
-import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
-import com.fasterxml.jackson.databind.JsonMappingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
@@ -23,100 +20,12 @@ import com.flipkart.zjsonpatch.JsonDiff;
 import io.kroxylicious.proxy.internal.filter.ProduceRequestTransformationFilter;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class ConfigurationTest {
 
     private static final ObjectMapper MAPPER = new ObjectMapper(new YAMLFactory());
     private final ConfigParser configParser = new ConfigParser();
 
-    public static Stream<Arguments> yamlDeserializeSerializeFidelity() {
-        return Stream.of(Arguments.of("Top level flags", """
-                useIoUring: true
-                """),
-                Arguments.of("Virtual cluster (PortPerBroker)", """
-                        virtualClusters:
-                          demo1:
-                            targetCluster:
-                              bootstrap_servers: kafka.example:1234
-                            clusterNetworkAddressConfigProvider:
-                              type: PortPerBroker
-                              config:
-                                bootstrapAddress: cluster1:9192
-                                numberOfBrokerPorts: 1
-                                brokerAddressPattern: localhost
-                                brokerStartPort: 9193
-                        """),
-                Arguments.of("Virtual cluster (SniRouting)", """
-                        virtualClusters:
-                          demo1:
-                            targetCluster:
-                              bootstrap_servers: kafka.example:1234
-                            clusterNetworkAddressConfigProvider:
-                              type: SniRouting
-                              config:
-                                bootstrapAddress: cluster1:9192
-                                brokerAddressPattern: broker-$(nodeId)
-                        """),
-                Arguments.of("Downstream/Upstream TLS", """
-                        virtualClusters:
-                          demo1:
-                            tls:
-                                key:
-                                  storeFile: /tmp/foo.jks
-                                  storePassword:
-                                    password: password
-                                  storeType: JKS
-                            targetCluster:
-                              bootstrap_servers: kafka.example:1234
-                              tls:
-                                trust:
-                                 storeFile: /tmp/foo.jks
-                                 storePassword:
-                                   password: password
-                                 storeType: JKS
-                            clusterNetworkAddressConfigProvider:
-                              type: SniRouting
-                              config:
-                                bootstrapAddress: cluster1:9192
-                                brokerAddressPattern: broker-$(nodeId)
-                        """),
-                Arguments.of("Filters", """
-                        filters:
-                        - type: ProduceRequestTransformation
-                          config:
-                            transformation: io.kroxylicious.proxy.internal.filter.ProduceRequestTransformationFilter$UpperCasing
-                        """),
-                Arguments.of("Admin", """
-                        adminHttp:
-                          host: 0.0.0.0
-                          port: 9193
-                          endpoints: {}
-                        """),
-                Arguments.of("Micrometer", """
-                        micrometer:
-                        - type: CommonTags
-                          config:
-                            commonTags:
-                              zone: "euc-1a"
-                              owner: "becky"
-                        """));
-    }
-
-    @ParameterizedTest(name = "{0}")
-    @MethodSource
-    void yamlDeserializeSerializeFidelity(String name, String config) throws Exception {
-
-        var configuration = configParser.parseConfiguration(config);
-        var roundTripped = configParser.toYaml(configuration);
-
-        var originalJsonNode = MAPPER.reader().readValue(config, JsonNode.class);
-        var roundTrippedJsonNode = MAPPER.reader().readValue(roundTripped, JsonNode.class);
-        var diff = JsonDiff.asJson(originalJsonNode, roundTrippedJsonNode);
-        assertThat(diff).isEmpty();
-
-    }
-
     public static Stream<Arguments> fluentApiConfigYamlFidelity() {
         return Stream.of(Arguments.of("Top level",
                 new ConfigurationBuilder().withUseIoUring(true).build(),
@@ -296,65 +205,6 @@ class ConfigurationTest {
         var expectedJson = MAPPER.reader().readValue(expected, JsonNode.class);
         var diff = JsonDiff.asJson(actualJson, expectedJson);
         assertThat(diff).isEmpty();
-
-    }
-
-    @Test
-    void shouldConfigureClusterNameFromNodeName() {
-        // Given
-        final Configuration configurationModel = configParser.parseConfiguration("""
-                virtualClusters:
-                  myAwesomeCluster:
-                    targetCluster:
-                      bootstrap_servers: kafka.example:1234
-                    clusterNetworkAddressConfigProvider:
-                      type: PortPerBroker
-                      config:
-                        bootstrapAddress: cluster1:9192
-                        numberOfBrokerPorts: 1
-                        brokerAddressPattern: localhost
-                        brokerStartPort: 9193
-                """);
-        // When
-        final List<io.kroxylicious.proxy.model.VirtualCluster> actualValidClusters = configurationModel.virtualClusterModel();
-
-        // Then
-        assertThat(actualValidClusters).singleElement().extracting("clusterName").isEqualTo("myAwesomeCluster");
     }
 
-    @Test
-    void shouldDetectDuplicateClusterNodeNames() {
-        // Given
-        assertThatThrownBy(() ->
-        // When
-        configParser.parseConfiguration("""
-                virtualClusters:
-                  demo1:
-                    targetCluster:
-                      bootstrap_servers: kafka.example:1234
-                    clusterNetworkAddressConfigProvider:
-                      type: PortPerBroker
-                      config:
-                        bootstrapAddress: cluster1:9192
-                        numberOfBrokerPorts: 1
-                        brokerAddressPattern: localhost
-                        brokerStartPort: 9193
-                  demo1:
-                    targetCluster:
-                      bootstrap_servers: magic-kafka.example:1234
-                    clusterNetworkAddressConfigProvider:
-                      type: PortPerBroker
-                      config:
-                        bootstrapAddress: cluster2:9193
-                        numberOfBrokerPorts: 1
-                        brokerAddressPattern: localhost
-                        brokerStartPort: 10193
-                """))
-                // Then
-                .isInstanceOf(IllegalArgumentException.class)
-                .hasCauseInstanceOf(JsonMappingException.class) // Debatable to enforce the wrapped JsonMappingException
-                .cause()
-                .hasMessageStartingWith("Duplicate field 'demo1'");
-
-    }
 }
diff --git a/kroxylicious/src/test/java/io/kroxylicious/proxy/config/AbstractDefinitionBuilderTest.java b/kroxylicious/src/test/java/io/kroxylicious/proxy/config/AbstractDefinitionBuilderTest.java
new file mode 100644
index 00000000..793f8510
--- /dev/null
+++ b/kroxylicious/src/test/java/io/kroxylicious/proxy/config/AbstractDefinitionBuilderTest.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.proxy.config;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class AbstractDefinitionBuilderTest {
+
+    public static final String TYPE = "concrete";
+
+    record Built(String type, Map<String, Object> config) {
+
+    }
+
+    private AbstractDefinitionBuilder<Built> builder = new AbstractDefinitionBuilder<>(TYPE) {
+        @Override
+        protected Built buildInternal(String type, Map<String, Object> config) {
+            return new Built(type, config);
+        }
+    };
+
+    @Test
+    void testTwoArgWithConfig() {
+        Built build = builder.withConfig("a", "b").build();
+        Map<String, String> a = Map.of("a", "b");
+        assertBuiltEquals(build, a);
+    }
+
+    @Test
+    void testFourArgWithConfig() {
+        Built build = builder.withConfig("a", "b", "c", "d").build();
+        assertBuiltEquals(build, Map.of("a", "b", "c", "d"));
+    }
+
+    @Test
+    void testSixArgWithConfig() {
+        Built build = builder.withConfig("a", "b", "c", "d", "e", "f").build();
+        assertBuiltEquals(build, Map.of("a", "b", "c", "d", "e", "f"));
+    }
+
+    @Test
+    void testEightArgWithConfig() {
+        Built build = builder.withConfig("a", "b", "c", "d", "e", "f", "g", "h").build();
+        assertBuiltEquals(build, Map.of("a", "b", "c", "d", "e", "f", "g", "h"));
+    }
+
+    @Test
+    void testMapWithConfig() {
+        Built build = builder.withConfig(Map.of("a", "b")).build();
+        assertBuiltEquals(build, Map.of("a", "b"));
+    }
+
+    @Test
+    void testEmptyMapWithConfig() {
+        Built build = builder.withConfig(Map.of()).build();
+        assertBuiltEquals(build, Map.of());
+    }
+
+    @Test
+    void testMapWithConfigNull() {
+        assertThrows(NullPointerException.class, () -> this.builder.withConfig(null));
+    }
+
+    // this helps ensure we generate output YAML deterministically
+    @Test
+    void testOrderMaintained() {
+        Built build = builder.withConfig("a", "b").withConfig("e", "f").withConfig("c", "d").build();
+        List<String> keys = build.config.keySet().stream().toList();
+        assertThat(keys).containsExactly("a", "e", "c");
+    }
+
+    private static void assertBuiltEquals(Built build, Map<String, String> a) {
+        assertThat(build.type).isEqualTo(TYPE);
+        assertThat(build.config).isEqualTo(a);
+    }
+}
\ No newline at end of file
diff --git a/kroxylicious/src/test/java/io/kroxylicious/proxy/config/ConfigParserTest.java b/kroxylicious/src/test/java/io/kroxylicious/proxy/config/ConfigParserTest.java
new file mode 100644
index 00000000..9376cb28
--- /dev/null
+++ b/kroxylicious/src/test/java/io/kroxylicious/proxy/config/ConfigParserTest.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright Kroxylicious Authors.
+ *
+ * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+package io.kroxylicious.proxy.config;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.flipkart.zjsonpatch.JsonDiff;
+
+import io.kroxylicious.proxy.config.admin.AdminHttpConfiguration;
+import io.kroxylicious.proxy.internal.clusternetworkaddressconfigprovider.PortPerBrokerClusterNetworkAddressConfigProvider.PortPerBrokerClusterNetworkAddressConfigProviderConfig;
+import io.kroxylicious.proxy.service.HostPort;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.Assert.assertThrows;
+
+class ConfigParserTest {
+    private static final ObjectMapper MAPPER = new ObjectMapper(new YAMLFactory());
+    // Given
+    private ConfigParser configParser = new ConfigParser();
+
+    public static Stream<Arguments> yamlDeserializeSerializeFidelity() {
+        return Stream.of(Arguments.of("Top level flags", """
+                useIoUring: true
+                """),
+                Arguments.of("Virtual cluster (PortPerBroker)", """
+                        virtualClusters:
+                          demo1:
+                            targetCluster:
+                              bootstrap_servers: kafka.example:1234
+                            clusterNetworkAddressConfigProvider:
+                              type: PortPerBroker
+                              config:
+                                bootstrapAddress: cluster1:9192
+                                numberOfBrokerPorts: 1
+                                brokerAddressPattern: localhost
+                                brokerStartPort: 9193
+                        """),
+                Arguments.of("Virtual cluster (SniRouting)", """
+                        virtualClusters:
+                          demo1:
+                            targetCluster:
+                              bootstrap_servers: kafka.example:1234
+                            clusterNetworkAddressConfigProvider:
+                              type: SniRouting
+                              config:
+                                bootstrapAddress: cluster1:9192
+                                brokerAddressPattern: broker-$(nodeId)
+                        """),
+                Arguments.of("Downstream/Upstream TLS", """
+                        virtualClusters:
+                          demo1:
+                            tls:
+                                key:
+                                  storeFile: /tmp/foo.jks
+                                  storePassword:
+                                    password: password
+                                  storeType: JKS
+                            targetCluster:
+                              bootstrap_servers: kafka.example:1234
+                              tls:
+                                trust:
+                                 storeFile: /tmp/foo.jks
+                                 storePassword:
+                                   password: password
+                                 storeType: JKS
+                            clusterNetworkAddressConfigProvider:
+                              type: SniRouting
+                              config:
+                                bootstrapAddress: cluster1:9192
+                                brokerAddressPattern: broker-$(nodeId)
+                        """),
+                Arguments.of("Filters", """
+                        filters:
+                        - type: ProduceRequestTransformation
+                          config:
+                            transformation: io.kroxylicious.proxy.internal.filter.ProduceRequestTransformationFilter$UpperCasing
+                        """),
+                Arguments.of("Admin", """
+                        adminHttp:
+                          host: 0.0.0.0
+                          port: 9193
+                          endpoints: {}
+                        """),
+                Arguments.of("Micrometer", """
+                        micrometer:
+                        - type: CommonTags
+                          config:
+                            commonTags:
+                              zone: "euc-1a"
+                              owner: "becky"
+                        """),
+                Arguments.of("AdminHttp", """
+                        adminHttp:
+                          host: kroxy
+                          port: 9093
+                          endpoints:
+                            prometheus: {}
+                        """));
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource
+    void yamlDeserializeSerializeFidelity(String name, String config) throws Exception {
+        var configuration = configParser.parseConfiguration(config);
+        var roundTripped = configParser.toYaml(configuration);
+
+        var originalJsonNode = MAPPER.reader().readValue(config, JsonNode.class);
+        var roundTrippedJsonNode = MAPPER.reader().readValue(roundTripped, JsonNode.class);
+        var diff = JsonDiff.asJson(originalJsonNode, roundTrippedJsonNode);
+        assertThat(diff).isEmpty();
+    }
+
+    @Test
+    void testDeserializeFromYaml() {
+        Configuration configuration = configParser.parseConfiguration(this.getClass().getClassLoader().getResourceAsStream("config.yml"));
+        assertThat(configuration.isUseIoUring()).isTrue();
+        AdminHttpConfiguration adminHttpConfiguration = configuration.adminHttpConfig();
+        assertThat(adminHttpConfiguration.host()).isEqualTo("kroxy");
+        assertThat(adminHttpConfiguration.port()).isEqualTo(9093);
+        assertThat(adminHttpConfiguration.endpoints().maybePrometheus()).isPresent();
+        assertThat(configuration.virtualClusters()).hasSize(1);
+        assertThat(configuration.virtualClusters().keySet()).containsExactly("demo");
+        VirtualCluster cluster = configuration.virtualClusters().values().iterator().next();
+        assertThat(cluster.logFrames()).isTrue();
+        assertThat(cluster.logNetwork()).isTrue();
+        assertThat(cluster.targetCluster()).isNotNull();
+        assertThat(cluster.targetCluster().bootstrapServers()).isEqualTo("localhost:9092");
+        ClusterNetworkAddressConfigProviderDefinition provider = cluster.clusterNetworkAddressConfigProvider();
+        assertThat(provider).isNotNull();
+        assertThat(provider.type()).isEqualTo("PortPerBroker");
+        assertThat(provider.config()).isInstanceOf(PortPerBrokerClusterNetworkAddressConfigProviderConfig.class);
+        assertThat(((PortPerBrokerClusterNetworkAddressConfigProviderConfig) provider.config()).getBootstrapAddress()).isEqualTo(HostPort.parse("localhost:9192"));
+    }
+
+    @Test
+    void testConfigParserBadJson() {
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> configParser.parseConfiguration("}"));
+        assertThat(exception.getMessage()).contains("Couldn't parse configuration");
+    }
+
+    @Test
+    void testConfigParserIoException() {
+        InputStream mockInputStream = new InputStream() {
+            @Override
+            public int read() throws IOException {
+                throw new IOException("I am the worst byte stream");
+            }
+        };
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> configParser.parseConfiguration(mockInputStream));
+        assertThat(exception.getMessage()).contains("Couldn't parse configuration");
+        assertThat(exception.getCause()).isInstanceOf(IOException.class);
+        assertThat(exception.getCause()).hasMessageContaining("I am the worst byte stream");
+    }
+
+    @Test
+    void shouldConfigureClusterNameFromNodeName() {
+        final Configuration configurationModel = configParser.parseConfiguration("""
+                virtualClusters:
+                  myAwesomeCluster:
+                    targetCluster:
+                      bootstrap_servers: kafka.example:1234
+                    clusterNetworkAddressConfigProvider:
+                      type: PortPerBroker
+                      config:
+                        bootstrapAddress: cluster1:9192
+                        numberOfBrokerPorts: 1
+                        brokerAddressPattern: localhost
+                        brokerStartPort: 9193
+                """);
+        // When
+        final List<io.kroxylicious.proxy.model.VirtualCluster> actualValidClusters = configurationModel.virtualClusterModel();
+
+        // Then
+        assertThat(actualValidClusters).singleElement().extracting("clusterName").isEqualTo("myAwesomeCluster");
+    }
+
+    @Test
+    void shouldDetectDuplicateClusterNodeNames() {
+        // Given
+        assertThatThrownBy(() ->
+        // When
+        configParser.parseConfiguration("""
+                virtualClusters:
+                  demo1:
+                    targetCluster:
+                      bootstrap_servers: kafka.example:1234
+                    clusterNetworkAddressConfigProvider:
+                      type: PortPerBroker
+                      config:
+                        bootstrapAddress: cluster1:9192
+                        numberOfBrokerPorts: 1
+                        brokerAddressPattern: localhost
+                        brokerStartPort: 9193
+                  demo1:
+                    targetCluster:
+                      bootstrap_servers: magic-kafka.example:1234
+                    clusterNetworkAddressConfigProvider:
+                      type: PortPerBroker
+                      config:
+                        bootstrapAddress: cluster2:9193
+                        numberOfBrokerPorts: 1
+                        brokerAddressPattern: localhost
+                        brokerStartPort: 10193
+                """))
+                // Then
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasCauseInstanceOf(JsonMappingException.class) // Debatable to enforce the wrapped JsonMappingException
+                .cause()
+                .hasMessageStartingWith("Duplicate field 'demo1'");
+
+    }
+
+}
diff --git a/kroxylicious/src/test/resources/config.yml b/kroxylicious/src/test/resources/config.yml
new file mode 100644
index 00000000..fc934608
--- /dev/null
+++ b/kroxylicious/src/test/resources/config.yml
@@ -0,0 +1,24 @@
+#
+# Copyright Kroxylicious Authors.
+#
+# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
+#
+
+---
+useIoUring: true
+adminHttp:
+  host: kroxy
+  port: 9093
+  endpoints:
+    prometheus: {}
+virtualClusters:
+  demo:
+    targetCluster:
+      bootstrap_servers: localhost:9092
+    clusterNetworkAddressConfigProvider:
+      type: PortPerBroker
+      config:
+        bootstrapAddress: localhost:9192
+    logNetwork: true
+    logFrames: true
+filters:
\ No newline at end of file