This is an automated email from the ASF dual-hosted git repository.

pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 261167a57af NIFI-16435: Drop undeclared connector properties on 
provider save (#11776)
261167a57af is described below

commit 261167a57afcb195a3d54a2bcd396b12d811d234
Author: Bob Paulin <[email protected]>
AuthorDate: Thu Oct 8 09:51:15 2026 -0500

    NIFI-16435: Drop undeclared connector properties on provider save (#11776)
    
    - Filter the loaded provider configuration to declared steps and
    properties before merging
    - Skip undeclared incoming properties and do not recreate undeclared
    steps
    - Add unit test and ConnectorUndeclaredExternalPropertyIT
---
 .../connector/StandardConnectorRepository.java     |  78 +++++++++-
 .../connector/TestStandardConnectorRepository.java |  63 ++++++++
 .../nifi-system-test-extensions/pom.xml            |   6 +
 ...aredPropertyConnectorConfigurationProvider.java | 162 +++++++++++++++++++++
 ...onents.connector.ConnectorConfigurationProvider |  16 ++
 .../ConnectorUndeclaredExternalPropertyIT.java     | 156 ++++++++++++++++++++
 6 files changed, 473 insertions(+), 8 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
index 8dce9108847..35bebe87e87 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
@@ -1138,36 +1138,98 @@ public class StandardConnectorRepository implements 
ConnectorRepository {
             ? new ArrayList<>(existingConfig.getWorkingFlowConfiguration())
             : new ArrayList<>();
 
-        VersionedConfigurationStep targetStep = null;
+        final Map<String, Set<String>> declaredPropertiesByStep = 
declaredPropertiesByStep(connector);
+        final List<VersionedConfigurationStep> retainedSteps = new 
ArrayList<>();
         for (final VersionedConfigurationStep step : existingSteps) {
+            final Set<String> declaredPropertyNames = 
declaredPropertiesByStep.get(step.getName());
+            if (declaredPropertyNames == null) {
+                logger.debug("Omitting configuration step [{}] from the saved 
configuration of {} because the current Connector does not declare it",
+                    step.getName(), connector);
+                continue;
+            }
+
+            final Map<String, VersionedConnectorValueReference> 
retainedProperties = new HashMap<>();
+            if (step.getProperties() != null) {
+                for (final Map.Entry<String, VersionedConnectorValueReference> 
propertyEntry : step.getProperties().entrySet()) {
+                    if 
(declaredPropertyNames.contains(propertyEntry.getKey())) {
+                        retainedProperties.put(propertyEntry.getKey(), 
propertyEntry.getValue());
+                    } else {
+                        logger.debug("Omitting property [{}] of step [{}] from 
the saved configuration of {} because the current Connector does not declare 
it",
+                            propertyEntry.getKey(), step.getName(), connector);
+                    }
+                }
+            }
+
+            final VersionedConfigurationStep retainedStep = new 
VersionedConfigurationStep();
+            retainedStep.setName(step.getName());
+            retainedStep.setProperties(retainedProperties);
+            retainedSteps.add(retainedStep);
+        }
+
+        VersionedConfigurationStep targetStep = null;
+        for (final VersionedConfigurationStep step : retainedSteps) {
             if (stepName.equals(step.getName())) {
                 targetStep = step;
                 break;
             }
         }
 
+        // The filter above only removes stored steps. The block below creates 
a missing step, so return here when the requested step is not declared.
+        if (targetStep == null && 
!declaredPropertiesByStep.containsKey(stepName)) {
+            logger.debug("Omitting configuration step [{}] from the saved 
configuration of {} because the current Connector does not declare it",
+                stepName, connector);
+            existingConfig.setWorkingFlowConfiguration(retainedSteps);
+            return existingConfig;
+        }
+
         if (targetStep == null) {
             targetStep = new VersionedConfigurationStep();
             targetStep.setName(stepName);
             targetStep.setProperties(new HashMap<>());
-            existingSteps.add(targetStep);
+            retainedSteps.add(targetStep);
         }
 
-        final Map<String, VersionedConnectorValueReference> mergedProperties = 
targetStep.getProperties() != null
-            ? new HashMap<>(targetStep.getProperties())
-            : new HashMap<>();
+        final Map<String, VersionedConnectorValueReference> mergedProperties = 
new HashMap<>(targetStep.getProperties());
 
+        final Set<String> declaredPropertyNames = 
declaredPropertiesByStep.get(stepName);
         for (final Map.Entry<String, ConnectorValueReference> entry : 
incomingConfiguration.getPropertyValues().entrySet()) {
-            if (entry.getValue() != null) {
-                mergedProperties.put(entry.getKey(), 
toVersionedValueReference(entry.getValue()));
+            if (entry.getValue() == null) {
+                continue;
+            }
+
+            if (!declaredPropertyNames.contains(entry.getKey())) {
+                logger.debug("Omitting property [{}] of step [{}] from the 
saved configuration of {} because the current Connector does not declare it",
+                    entry.getKey(), stepName, connector);
+                continue;
             }
+
+            mergedProperties.put(entry.getKey(), 
toVersionedValueReference(entry.getValue()));
         }
         targetStep.setProperties(mergedProperties);
 
-        existingConfig.setWorkingFlowConfiguration(existingSteps);
+        existingConfig.setWorkingFlowConfiguration(retainedSteps);
         return existingConfig;
     }
 
+    /**
+     * Returns the property names declared by the current Connector, keyed by 
configuration step name.
+     */
+    private Map<String, Set<String>> declaredPropertiesByStep(final 
ConnectorNode connector) {
+        final Map<String, Set<String>> declaredPropertiesByStep = new 
HashMap<>();
+        for (final ConfigurationStep configurationStep : 
connector.getConfigurationSteps()) {
+            final Set<String> propertyNames = new HashSet<>();
+            for (final ConnectorPropertyGroup propertyGroup : 
configurationStep.getPropertyGroups()) {
+                for (final ConnectorPropertyDescriptor descriptor : 
propertyGroup.getProperties()) {
+                    propertyNames.add(descriptor.getName());
+                }
+            }
+
+            declaredPropertiesByStep.put(configurationStep.getName(), 
propertyNames);
+        }
+
+        return declaredPropertiesByStep;
+    }
+
     private Map<String, VersionedConnectorValueReference> 
toVersionedProperties(final StepConfiguration configuration) {
         final Map<String, VersionedConnectorValueReference> 
versionedProperties = new HashMap<>();
         for (final Map.Entry<String, ConnectorValueReference> entry : 
configuration.getPropertyValues().entrySet()) {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
index 09bf1a56ca6..c9c31862b00 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
@@ -536,6 +536,7 @@ public class TestStandardConnectorRepository {
         final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
 
         final ConnectorNode connector = 
createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+        
when(connector.getConfigurationSteps()).thenReturn(List.of(declaredConfigurationStep("step1",
 "prop1")));
         repository.addConnector(connector);
 
         when(provider.load("connector-1")).thenReturn(Optional.empty());
@@ -567,6 +568,7 @@ public class TestStandardConnectorRepository {
         final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
 
         final ConnectorNode connector = 
createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+        
when(connector.getConfigurationSteps()).thenReturn(List.of(declaredConfigurationStep("step1",
 "prop1")));
         repository.addConnector(connector);
 
         when(provider.load("connector-1")).thenReturn(Optional.empty());
@@ -585,6 +587,7 @@ public class TestStandardConnectorRepository {
         final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
 
         final ConnectorNode connector = 
createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+        
when(connector.getConfigurationSteps()).thenReturn(List.of(declaredConfigurationStep("step1",
 "propA", "propB", "propC")));
         repository.addConnector(connector);
 
         final VersionedConfigurationStep existingStep = 
createVersionedStep("step1",
@@ -617,6 +620,46 @@ public class TestStandardConnectorRepository {
         verify(connector).setConfiguration("step1", incomingConfig);
     }
 
+    @Test
+    public void testConfigureConnectorDropsUndeclaredExternalProperties() 
throws FlowUpdateException {
+        final ConnectorConfigurationProvider provider = 
mock(ConnectorConfigurationProvider.class);
+        final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
+
+        final ConnectorNode connector = 
createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+        
when(connector.getConfigurationSteps()).thenReturn(List.of(declaredConfigurationStep("step1",
 "propA", "propC")));
+        repository.addConnector(connector);
+
+        final VersionedConfigurationStep existingStep = 
createVersionedStep("step1",
+            Map.of("propA", createStringLiteralRef("old-A"), "propB", 
createStringLiteralRef("old-B"), "propC", createStringLiteralRef("old-C")));
+        final VersionedConfigurationStep undeclaredStep = 
createVersionedStep("legacy",
+            Map.of("obsolete", createStringLiteralRef("gone")));
+        final ConnectorWorkingConfiguration existingConfig = new 
ConnectorWorkingConfiguration();
+        existingConfig.setName("Test Connector");
+        existingConfig.setWorkingFlowConfiguration(new 
ArrayList<>(List.of(existingStep, undeclaredStep)));
+        
when(provider.load("connector-1")).thenReturn(Optional.of(existingConfig));
+
+        final Map<String, ConnectorValueReference> incomingProps = new 
HashMap<>();
+        incomingProps.put("propA", new StringLiteralValue("new-A"));
+        incomingProps.put("propD", new StringLiteralValue("not-declared"));
+        final StepConfiguration incomingConfig = new 
StepConfiguration(incomingProps);
+
+        repository.configureConnector(connector, "step1", incomingConfig);
+
+        final ArgumentCaptor<ConnectorWorkingConfiguration> configCaptor = 
ArgumentCaptor.forClass(ConnectorWorkingConfiguration.class);
+        verify(provider).save(eq("connector-1"), configCaptor.capture());
+
+        final ConnectorWorkingConfiguration savedConfig = 
configCaptor.getValue();
+        assertEquals(1, savedConfig.getWorkingFlowConfiguration().size());
+        final VersionedConfigurationStep savedStep = 
savedConfig.getWorkingFlowConfiguration().getFirst();
+        assertEquals("step1", savedStep.getName());
+
+        final Map<String, VersionedConnectorValueReference> savedProps = 
savedStep.getProperties();
+        assertEquals("new-A", savedProps.get("propA").getValue());
+        assertEquals("old-C", savedProps.get("propC").getValue());
+        assertFalse(savedProps.containsKey("propB"));
+        assertFalse(savedProps.containsKey("propD"));
+    }
+
     @Test
     public void testDiscardWorkingConfigurationCallsProviderDiscard() {
         final ConnectorConfigurationProvider provider = 
mock(ConnectorConfigurationProvider.class);
@@ -2010,6 +2053,26 @@ public class TestStandardConnectorRepository {
         return connector;
     }
 
+    private ConfigurationStep declaredConfigurationStep(final String stepName, 
final String... propertyNames) {
+        final List<ConnectorPropertyDescriptor> descriptors = new 
ArrayList<>();
+        for (final String propertyName : propertyNames) {
+            descriptors.add(new ConnectorPropertyDescriptor.Builder()
+                .name(propertyName)
+                .description(propertyName)
+                .required(false)
+                .build());
+        }
+
+        return new ConfigurationStep.Builder()
+            .name(stepName)
+            .propertyGroups(List.of(ConnectorPropertyGroup.builder()
+                .name("Properties")
+                .description("Declared properties")
+                .properties(descriptors)
+                .build()))
+            .build();
+    }
+
     private VersionedConfigurationStep createVersionedStep(final String name, 
final Map<String, VersionedConnectorValueReference> properties) {
         final VersionedConfigurationStep step = new 
VersionedConfigurationStep();
         step.setName(name);
diff --git 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/pom.xml
 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/pom.xml
index a1b61e603c4..ea90f522090 100644
--- 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/pom.xml
+++ 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/pom.xml
@@ -54,6 +54,12 @@
             <artifactId>nifi-connector-common</artifactId>
             <version>2.13.0-SNAPSHOT</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-framework-api</artifactId>
+            <version>2.13.0-SNAPSHOT</version>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.nifi</groupId>
             <artifactId>nifi-system-test-extensions-services-api</artifactId>
diff --git 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/UndeclaredPropertyConnectorConfigurationProvider.java
 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/UndeclaredPropertyConnectorConfigurationProvider.java
new file mode 100644
index 00000000000..c03d7d8e860
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/UndeclaredPropertyConnectorConfigurationProvider.java
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.connectors.tests.system;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.components.connector.ConnectorConfigurationProvider;
+import 
org.apache.nifi.components.connector.ConnectorConfigurationProviderException;
+import 
org.apache.nifi.components.connector.ConnectorConfigurationProviderInitializationContext;
+import org.apache.nifi.components.connector.ConnectorWorkingConfiguration;
+import org.apache.nifi.flow.VersionedConfigurationStep;
+import org.apache.nifi.flow.VersionedConnectorValueReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory configuration provider whose initial configuration for an unsaved 
connector includes a property
+ * the test connector does not declare. Each save is also written to disk so a 
system test can observe properties
+ * that the connector REST API does not return.
+ */
+public class UndeclaredPropertyConnectorConfigurationProvider implements 
ConnectorConfigurationProvider {
+    public static final String STATE_DIRECTORY_PROPERTY = "state.directory";
+    public static final String INITIAL_CONFIG_FILE_NAME = 
"initial-config.json";
+    public static final String SAVED_CONFIG_FILE_NAME = "saved-config.json";
+
+    static final String STEP_NAME = "Ignored Step";
+    static final String DECLARED_PROPERTY_NAME = "Ignored Property";
+    static final String DECLARED_PROPERTY_VALUE = "from-external-config";
+    static final String UNDECLARED_PROPERTY_NAME = "Removed Property";
+    static final String UNDECLARED_PROPERTY_VALUE = "should-be-dropped";
+
+    private static final Logger logger = 
LoggerFactory.getLogger(UndeclaredPropertyConnectorConfigurationProvider.class);
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    private final Map<String, ConnectorWorkingConfiguration> 
savedConfigurations = new ConcurrentHashMap<>();
+
+    private File stateDirectory;
+
+    @Override
+    public void initialize(final 
ConnectorConfigurationProviderInitializationContext context) throws 
ConnectorConfigurationProviderException {
+        final String stateDirectoryPath = 
context.getProperties().get(STATE_DIRECTORY_PROPERTY);
+        if (stateDirectoryPath == null || stateDirectoryPath.isBlank()) {
+            throw new ConnectorConfigurationProviderException("Connector 
configuration provider property [%s] is 
required".formatted(STATE_DIRECTORY_PROPERTY));
+        }
+
+        stateDirectory = new File(stateDirectoryPath);
+        if (!stateDirectory.isDirectory() && !stateDirectory.mkdirs()) {
+            throw new ConnectorConfigurationProviderException("Unable to 
create connector configuration provider state directory 
[%s]".formatted(stateDirectoryPath));
+        }
+
+        writeConfiguration(new File(stateDirectory, INITIAL_CONFIG_FILE_NAME), 
seedConfiguration());
+    }
+
+    @Override
+    public Optional<ConnectorWorkingConfiguration> load(final String 
connectorId) {
+        final ConnectorWorkingConfiguration savedConfiguration = 
savedConfigurations.get(connectorId);
+        if (savedConfiguration != null) {
+            return Optional.of(copy(savedConfiguration));
+        }
+
+        return Optional.of(seedConfiguration());
+    }
+
+    @Override
+    public void save(final String connectorId, final 
ConnectorWorkingConfiguration configuration) {
+        final ConnectorWorkingConfiguration savedConfiguration = 
copy(configuration);
+        savedConfigurations.put(connectorId, savedConfiguration);
+        writeConfiguration(new File(stateDirectory, SAVED_CONFIG_FILE_NAME), 
savedConfiguration);
+    }
+
+    @Override
+    public void discard(final String connectorId) {
+        logger.debug("Discard is a no-op for connector {}", connectorId);
+    }
+
+    @Override
+    public void delete(final String connectorId) {
+        savedConfigurations.remove(connectorId);
+    }
+
+    @Override
+    public void verifyCreate(final String connectorId) {
+        logger.debug("Verify create is a no-op for connector {}", connectorId);
+    }
+
+    @Override
+    public void storeAsset(final String connectorId, final String nifiUuid, 
final String assetName, final InputStream content) {
+        logger.debug("Store asset is a no-op for connector {} and asset {}", 
connectorId, nifiUuid);
+    }
+
+    @Override
+    public void deleteAsset(final String connectorId, final String nifiUuid) {
+        logger.debug("Delete asset is a no-op for connector {} and asset {}", 
connectorId, nifiUuid);
+    }
+
+    @Override
+    public void syncAssets(final String connectorId) {
+        logger.debug("Sync assets is a no-op for connector {}", connectorId);
+    }
+
+    private ConnectorWorkingConfiguration seedConfiguration() {
+        final VersionedConnectorValueReference declaredValue = new 
VersionedConnectorValueReference();
+        declaredValue.setValueType("STRING_LITERAL");
+        declaredValue.setValue(DECLARED_PROPERTY_VALUE);
+
+        final VersionedConnectorValueReference undeclaredValue = new 
VersionedConnectorValueReference();
+        undeclaredValue.setValueType("STRING_LITERAL");
+        undeclaredValue.setValue(UNDECLARED_PROPERTY_VALUE);
+
+        final Map<String, VersionedConnectorValueReference> properties = new 
HashMap<>();
+        properties.put(DECLARED_PROPERTY_NAME, declaredValue);
+        properties.put(UNDECLARED_PROPERTY_NAME, undeclaredValue);
+
+        final VersionedConfigurationStep step = new 
VersionedConfigurationStep();
+        step.setName(STEP_NAME);
+        step.setProperties(properties);
+
+        final ConnectorWorkingConfiguration configuration = new 
ConnectorWorkingConfiguration();
+        configuration.setName("NopConnector");
+        configuration.setWorkingFlowConfiguration(new 
ArrayList<>(List.of(step)));
+        return configuration;
+    }
+
+    private ConnectorWorkingConfiguration copy(final 
ConnectorWorkingConfiguration configuration) {
+        try {
+            return 
OBJECT_MAPPER.readValue(OBJECT_MAPPER.writeValueAsBytes(configuration), 
ConnectorWorkingConfiguration.class);
+        } catch (final IOException e) {
+            throw new ConnectorConfigurationProviderException("Unable to copy 
connector working configuration", e);
+        }
+    }
+
+    private void writeConfiguration(final File destination, final 
ConnectorWorkingConfiguration configuration) {
+        try {
+            
OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValue(destination, 
configuration);
+        } catch (final IOException e) {
+            throw new ConnectorConfigurationProviderException("Unable to write 
connector working configuration to 
[%s]".formatted(destination.getAbsolutePath()), e);
+        }
+    }
+}
diff --git 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.ConnectorConfigurationProvider
 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.ConnectorConfigurationProvider
new file mode 100644
index 00000000000..56e344eb084
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.ConnectorConfigurationProvider
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+org.apache.nifi.connectors.tests.system.UndeclaredPropertyConnectorConfigurationProvider
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorUndeclaredExternalPropertyIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorUndeclaredExternalPropertyIT.java
new file mode 100644
index 00000000000..d565766d8b0
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorUndeclaredExternalPropertyIT.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.nifi.tests.system.connectors;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.tests.system.InstanceConfiguration;
+import org.apache.nifi.tests.system.NiFiInstanceFactory;
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.tests.system.SpawnedStandaloneNiFiInstanceFactory;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ConnectorConfigurationDTO;
+import org.apache.nifi.web.api.dto.ConnectorValueReferenceDTO;
+import org.apache.nifi.web.api.entity.ConnectorEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Verifies that saving a connector drops properties and steps that the 
current connector does not declare,
+ * even when the external configuration provider loaded them.
+ */
+public class ConnectorUndeclaredExternalPropertyIT extends NiFiSystemIT {
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    private static final String PROVIDER_CLASS = 
"org.apache.nifi.connectors.tests.system.UndeclaredPropertyConnectorConfigurationProvider";
+    private static final File STATE_DIRECTORY = new 
File("target/undeclared-external-property").getAbsoluteFile();
+    private static final String STEP_NAME = "Ignored Step";
+    private static final String DECLARED_PROPERTY_NAME = "Ignored Property";
+    private static final String DECLARED_PROPERTY_VALUE = 
"from-external-config";
+    private static final String UPDATED_PROPERTY_VALUE = "saved-by-test";
+    private static final String UNDECLARED_PROPERTY_NAME = "Removed Property";
+
+    @Override
+    public NiFiInstanceFactory getInstanceFactory() {
+        deleteRecursively(STATE_DIRECTORY);
+
+        return new SpawnedStandaloneNiFiInstanceFactory(
+            new InstanceConfiguration.Builder()
+                
.bootstrapConfig("src/test/resources/conf/default/bootstrap.conf")
+                .instanceDirectory("target/standalone-instance")
+                .overrideNifiProperties(Map.of(
+                    "nifi.connector.configuration.provider.implementation", 
PROVIDER_CLASS,
+                    
"nifi.connector.configuration.provider.properties.state.directory", 
STATE_DIRECTORY.getAbsolutePath()
+                ))
+                .build());
+    }
+
+    @Override
+    protected boolean isDestroyEnvironmentAfterEachTest() {
+        return true;
+    }
+
+    @Override
+    protected boolean isAllowFactoryReuse() {
+        return false;
+    }
+
+    @Test
+    public void testSaveDropsUndeclaredExternalProperty() throws 
NiFiClientException, IOException {
+        final JsonNode initialConfiguration = 
readConfiguration("initial-config.json");
+        assertEquals(DECLARED_PROPERTY_VALUE, 
propertyValue(initialConfiguration, DECLARED_PROPERTY_NAME));
+        assertEquals("should-be-dropped", propertyValue(initialConfiguration, 
UNDECLARED_PROPERTY_NAME));
+
+        final ConnectorEntity connector = 
getClientUtil().createConnector("NopConnector");
+        assertNotNull(connector);
+
+        final ConnectorEntity beforeSave = 
getNifiClient().getConnectorClient().getConnector(connector.getId());
+        assertEquals(DECLARED_PROPERTY_VALUE, 
ignoredPropertyValue(beforeSave));
+
+        getClientUtil().configureConnector(connector, STEP_NAME, 
Map.of(DECLARED_PROPERTY_NAME, UPDATED_PROPERTY_VALUE));
+
+        final JsonNode savedConfiguration = 
readConfiguration("saved-config.json");
+        assertEquals(UPDATED_PROPERTY_VALUE, propertyValue(savedConfiguration, 
DECLARED_PROPERTY_NAME));
+        assertFalse(containsProperty(savedConfiguration, 
UNDECLARED_PROPERTY_NAME));
+
+        final ConnectorEntity afterSave = 
getNifiClient().getConnectorClient().getConnector(connector.getId());
+        assertEquals(UPDATED_PROPERTY_VALUE, ignoredPropertyValue(afterSave));
+    }
+
+    private JsonNode readConfiguration(final String fileName) throws 
IOException {
+        return OBJECT_MAPPER.readTree(new File(STATE_DIRECTORY, fileName));
+    }
+
+    private String ignoredPropertyValue(final ConnectorEntity connector) {
+        final ConnectorConfigurationDTO workingConfiguration = 
connector.getComponent().getWorkingConfiguration();
+        final Map<String, ConnectorValueReferenceDTO> propertyValues = 
workingConfiguration.getConfigurationStepConfigurations().getFirst()
+            .getPropertyGroupConfigurations().getFirst()
+            .getPropertyValues();
+        return propertyValues.get(DECLARED_PROPERTY_NAME).getValue();
+    }
+
+    private String propertyValue(final JsonNode configuration, final String 
propertyName) {
+        final JsonNode property = findProperty(configuration, propertyName);
+        assertNotNull(property);
+        return property.get("value").asText();
+    }
+
+    private boolean containsProperty(final JsonNode configuration, final 
String propertyName) {
+        return findProperty(configuration, propertyName) != null;
+    }
+
+    private JsonNode findProperty(final JsonNode configuration, final String 
propertyName) {
+        final JsonNode steps = configuration.get("workingFlowConfiguration");
+        if (steps == null) {
+            return null;
+        }
+
+        for (final JsonNode step : steps) {
+            final JsonNode properties = step.get("properties");
+            if (properties != null && properties.has(propertyName)) {
+                return properties.get(propertyName);
+            }
+        }
+
+        return null;
+    }
+
+    private void deleteRecursively(final File file) {
+        if (!file.exists()) {
+            return;
+        }
+
+        final File[] children = file.listFiles();
+        if (children != null) {
+            for (final File child : children) {
+                deleteRecursively(child);
+            }
+        }
+
+        if (!file.delete()) {
+            throw new IllegalStateException("Unable to delete " + 
file.getAbsolutePath());
+        }
+    }
+}

Reply via email to