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 = {