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

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


The following commit(s) were added to refs/heads/main by this push:
     new cb3f5871592 fix ui connection test with null host and port  (#72348)
cb3f5871592 is described below

commit cb3f587159274fc49116c82b07d390a3750b71eb
Author: /-\ - Pedro Henrique Klein <[email protected]>
AuthorDate: Tue Sep 8 18:37:44 2026 -0300

    fix ui connection test with null host and port  (#72348)
    
    * fix(core): test connection api ui solved #72318
    
    * fix(ruff): format docstring
    
    * fix connection test with null host and port
---
 .../core_api/routes/public/connections.py          | 39 ++++++++++++--
 .../core_api/routes/public/test_connections.py     | 62 ++++++++++++++++++++++
 2 files changed, 97 insertions(+), 4 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
index 19378dd8aea..e828b932dea 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
@@ -104,6 +104,37 @@ def _ensure_executor_is_configured(executor: str | None) 
-> None:
         )
 
 
+_MASKED_CREDENTIAL_SENTINEL = "***"
+
+
+def _same_endpoint(requested: str | int | None, stored: str | int | None) -> 
bool:
+    """
+    Return True when request and stored host/port refer to the same 
destination.
+
+    The UI sends empty string for hidden unused host/port fields; the ORM 
stores
+    those as NULL. Treat blank as unset so connection types that do not use
+    host/port still reuse stored credentials.
+    """
+
+    def _norm(value: str | int | None) -> str | int | None:
+        return None if value is None or value == "" else value
+
+    return _norm(requested) == _norm(stored)
+
+
+def _supplies_own_credentials(test_body: ConnectionBody) -> bool:
+    """
+    Return True when the request includes a real (non-masked) password.
+
+    The UI always posts the masked sentinel for unchanged secrets. That is not
+    a caller-supplied credential and must not skip restoring stored extras.
+    """
+    if "password" not in test_body.model_fields_set:
+        return False
+    password = test_body.password
+    return bool(password) and password != _MASKED_CREDENTIAL_SENTINEL
+
+
 @connections_router.delete(
     "/{connection_id}",
     status_code=status.HTTP_204_NO_CONTENT,
@@ -353,10 +384,10 @@ def test_connection(
             # Stored credentials are only reused to test the connection's own
             # host/port; testing a different destination must supply its own.
             fields_set = test_body.model_fields_set
-            if ("host" in fields_set and test_body.host != existing_conn.host) 
or (
-                "port" in fields_set and test_body.port != existing_conn.port
-            ):
-                if "password" not in fields_set:
+            host_changed = "host" in fields_set and not 
_same_endpoint(test_body.host, existing_conn.host)
+            port_changed = "port" in fields_set and not 
_same_endpoint(test_body.port, existing_conn.port)
+            if host_changed or port_changed:
+                if not _supplies_own_credentials(test_body):
                     raise HTTPException(
                         status.HTTP_400_BAD_REQUEST,
                         "The host or port to test differs from the stored 
connection. "
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
index 231a7ca7b22..a9822e5afaf 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
@@ -1413,6 +1413,68 @@ class TestConnection(TestConnectionEndpoint):
         tested_connection = mock_test.call_args.args[0]
         assert tested_connection.password == expected_password
 
+    @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+    def test_should_reuse_stored_extra_when_host_and_port_are_blank(self, 
test_client, session):
+        """Hidden unused host/port (None vs "") must not skip restoring masked 
extra."""
+        stored_path = "/real.pem"
+        session.add(
+            Connection(
+                conn_id=TEST_CONN_ID,
+                conn_type="snowflake",
+                host=None,
+                port=None,
+                extra=json.dumps({"private_key_file": stored_path, "account": 
"acct"}),
+            )
+        )
+        session.commit()
+
+        captured = {}
+
+        def mock_test_connection(self):
+            captured["extra"] = self.extra
+            return True, "mocked"
+
+        body = {
+            "connection_id": TEST_CONN_ID,
+            "conn_type": "snowflake",
+            "host": "",
+            "password": "***",
+            "extra": json.dumps({"private_key_file": "***", "account": 
"acct"}),
+        }
+
+        with mock.patch.object(Connection, "test_connection", 
mock_test_connection):
+            response = test_client.post("/connections/test", json=body)
+
+        assert response.status_code == 200
+        assert json.loads(captured["extra"])["private_key_file"] == stored_path
+
+    @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+    def test_should_reject_overridden_target_when_password_is_masked(self, 
test_client, session):
+        """A masked password is not caller-supplied credentials for a new 
destination."""
+        session.add(
+            Connection(
+                conn_id=TEST_CONN_ID,
+                conn_type="sqlite",
+                host="stored_host",
+                port=1234,
+                password="existing_password",
+            )
+        )
+        session.commit()
+
+        body = {
+            "connection_id": TEST_CONN_ID,
+            "conn_type": "sqlite",
+            "host": "other_host",
+            "password": "***",
+        }
+        with mock.patch.object(Connection, "test_connection", autospec=True) 
as mock_test:
+            mock_test.return_value = (True, "mocked")
+            response = test_client.post("/connections/test", json=body)
+
+        assert response.status_code == 400
+        mock_test.assert_not_called()
+
     @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
     def test_should_test_new_connection_without_existing(self, test_client):
         body = {

Reply via email to