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

potiuk pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new d486f5cb317 Reuse stored credentials only when testing an unchanged 
connection (#76453) (#76791)
d486f5cb317 is described below

commit d486f5cb317cf713b8619978b8fb7daf0601dd35
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Nov 10 04:40:08 2026 +0100

    Reuse stored credentials only when testing an unchanged connection (#76453) 
(#76791)
    
    * Reuse stored credentials only when testing an unchanged connection
    
    Testing an existing connection reuses its stored password and extra so that 
it can be tested without typing the secrets again. Only a different host or 
port made the test require the caller's own credentials, but the connection 
type, schema, login and extra also decide how and where a hook connects: an 
HTTP schema can carry a full URL, and extra can set endpoints, proxies or TLS 
options.
    
    * Add a newsfragment for the connection test credential reuse change
    
    * Compare connection fields through one normalised view when testing
    
    Each field compared to decide whether stored credentials are reused now 
goes through one place, so adding a field is a single entry rather than another 
check.
    
    (cherry picked from commit a0b0c4dd6f8cf1493a0214adbfd07f3997981433)
---
 airflow-core/newsfragments/76453.significant.rst   | 16 ++++++++
 .../core_api/routes/public/connections.py          | 43 +++++++++++++---------
 .../core_api/routes/public/test_connections.py     | 35 +++++++++++++-----
 3 files changed, 66 insertions(+), 28 deletions(-)

diff --git a/airflow-core/newsfragments/76453.significant.rst 
b/airflow-core/newsfragments/76453.significant.rst
new file mode 100644
index 00000000000..f088f1b592e
--- /dev/null
+++ b/airflow-core/newsfragments/76453.significant.rst
@@ -0,0 +1,16 @@
+Testing an existing connection reuses its stored secrets only when the 
connection is unchanged
+
+``POST /api/v2/connections/test`` with the ``connection_id`` of an existing 
connection fills in the stored password and ``extra`` for the fields the 
request leaves out or sends back masked. It now does so only when the request 
keeps the stored connection type, host, port, schema, login and ``extra``. A 
request that changes any of them, including a partial request that only sets 
the changed fields, is rejected with a 400 unless it includes the password to 
test, and is then tested with exa [...]
+
+Testing a saved connection from the UI is not affected.
+
+* Types of change
+
+  * [ ] Dag changes
+  * [ ] Config changes
+  * [x] API changes
+  * [ ] CLI changes
+  * [x] Behaviour changes
+  * [ ] Plugin changes
+  * [ ] Dependency changes
+  * [ ] Code interface changes
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 7fa0151bae8..92687bfdd26 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
@@ -16,8 +16,9 @@
 # under the License.
 from __future__ import annotations
 
+import json
 import os
-from typing import TYPE_CHECKING, Annotated
+from typing import TYPE_CHECKING, Annotated, Any
 
 from fastapi import Depends, Header, HTTPException, Query, status
 from fastapi.exceptions import RequestValidationError
@@ -107,23 +108,21 @@ def _ensure_executor_is_configured(executor: str | None) 
-> None:
 _MASKED_CREDENTIAL_SENTINEL = "***"
 
 
-def _same_endpoint(requested: str | int | None, stored: str | int | None) -> 
bool:
+def _get_connection_target(conn: ConnectionBody | ConnectionResponse, fields: 
set[str]) -> dict[str, Any]:
     """
-    Return True when request and stored host/port refer to the same 
destination.
+    Return the fields that decide where and how a hook connects, with blank 
values as None.
 
-    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.
+    The UI sends empty string for hidden unused fields; the ORM stores those 
as NULL. Treat
+    blank as unset so connection types that do not use host/port still reuse 
stored credentials.
 
     Port arrives as ``0`` rather than blank, because the UI builds its body 
with
     ``Number(connection.port)`` and ``Number(null)`` is ``0``. No connection 
addresses
     port 0, so it means the same thing as blank here and is normalised with it.
     """
-
-    def _norm(value: str | int | None) -> str | int | None:
-        return None if value in (None, "", 0) else value
-
-    return _norm(requested) == _norm(stored)
+    target = {name: None if (value := getattr(conn, name)) in (None, "", 0) 
else value for name in fields}
+    if "extra" in target:
+        target["extra"] = json.loads(conn.extra or "{}")
+    return target
 
 
 def _supplies_own_credentials(test_body: ConnectionBody) -> bool:
@@ -389,16 +388,24 @@ def test_connection(
                 existing_conn = None
 
         if existing_conn is not None:
-            # 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
-            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:
+            # Stored credentials are only reused to test the stored connection 
itself;
+            # testing a different one must supply its own. Besides host and 
port, the
+            # connection type, schema, login and extra also decide where and 
how a hook
+            # connects (e.g. a URL in schema, or proxies and endpoints in 
extra).
+            compared = {"host", "port", "schema_", "login", "extra"} & 
test_body.model_fields_set
+            # conn_type goes first: a stored connection without one cannot be 
read as a
+            # ConnectionResponse, and it differs from the request anyway.
+            if test_body.conn_type != existing_conn.conn_type or 
_get_connection_target(
+                test_body, compared
+            ) != _get_connection_target(
+                # Compare with the stored connection as the API shows it, with 
its secrets masked.
+                ConnectionResponse.model_validate(existing_conn),
+                compared,
+            ):
                 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. "
+                        "The connection to test differs from the stored 
connection. "
                         "Include the credentials to test in the request body.",
                     )
                 existing_conn = None
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 f53932d3a00..fde94cbb5b0 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
@@ -1268,7 +1268,7 @@ class TestConnection(TestConnectionEndpoint):
         ) as spy_secrets:
             response = test_client.post(
                 "/connections/test",
-                json={"connection_id": TEST_CONN_ID, "conn_type": "sqlite"},
+                json={"connection_id": TEST_CONN_ID, "conn_type": 
TEST_CONN_TYPE},
             )
 
         assert response.status_code == 200
@@ -1353,7 +1353,7 @@ class TestConnection(TestConnectionEndpoint):
 
         body = {
             "connection_id": TEST_CONN_ID,
-            "conn_type": "new_sqlite",
+            "conn_type": "sqlite",
             "password": "***",
         }
 
@@ -1364,8 +1364,7 @@ class TestConnection(TestConnectionEndpoint):
         assert response.json()["status"] is True
         # Verify that the existing password was used, not "***"
         assert captured_value["password"] == "existing_password"
-        # Verify that payload info were used for other fields
-        assert captured_value["conn_type"] == "new_sqlite"
+        assert captured_value["conn_type"] == "sqlite"
 
         # Verify DB was not mutated
         session.expire_all()
@@ -1393,6 +1392,7 @@ class TestConnection(TestConnectionEndpoint):
         body = {
             "connection_id": TEST_CONN_ID,
             "conn_type": "fs",
+            "password": "caller_password",
             "extra": '{"path": "/", "new_key": "new_value"}',
         }
 
@@ -1451,7 +1451,12 @@ class TestConnection(TestConnectionEndpoint):
             captured_extra["value"] = self.extra
             return True, "mocked"
 
-        body = {"connection_id": TEST_CONN_ID, "conn_type": "sqlite", "extra": 
'{"path": "***"}'}
+        body = {
+            "connection_id": TEST_CONN_ID,
+            "conn_type": "sqlite",
+            "password": "caller_password",
+            "extra": '{"path": "***"}',
+        }
         with mock.patch.object(Connection, "test_connection", 
mock_test_connection):
             response = test_client.post("/connections/test", json=body)
 
@@ -1481,7 +1486,7 @@ class TestConnection(TestConnectionEndpoint):
             "connection_id": TEST_CONN_ID,
             "conn_type": "fs",
             "password": "***",
-            "extra": '{"path": "/", "new_key": "new_value"}',
+            "extra": '{"path": "/", "existing_key": "existing_value"}',
         }
 
         with mock.patch.object(Connection, "test_connection", 
mock_test_connection):
@@ -1491,10 +1496,7 @@ class TestConnection(TestConnectionEndpoint):
         assert response.json()["status"] is True
         # Verify that the existing password was used, not "***"
         assert captured_values["password"] == "existing_password"
-        # Verify that new_key is reflected in the merged extra
-        merged_extra = json.loads(captured_values["extra"])
-        assert merged_extra["new_key"] == "new_value"
-        assert merged_extra["path"] == "/"
+        assert json.loads(captured_values["extra"]) == {"path": "/", 
"existing_key": "existing_value"}
 
         # Verify DB was not mutated
         session.expire_all()
@@ -1509,6 +1511,16 @@ class TestConnection(TestConnectionEndpoint):
         [
             pytest.param({"host": "other_host"}, id="host-changed"),
             pytest.param({"host": "stored_host", "port": 9999}, 
id="port-changed"),
+            pytest.param({"conn_type": "http"}, id="conn-type-changed"),
+            pytest.param({"schema": "http://other_host:8080/path"}, 
id="schema-changed"),
+            pytest.param({"login": "other_login"}, id="login-changed"),
+            pytest.param(
+                {
+                    "password": "***",
+                    "extra": json.dumps({"api_key": "***", "proxies": {"http": 
"http://other"}}),
+                },
+                id="extra-changed",
+            ),
         ],
     )
     def test_should_reject_test_when_target_overridden_without_credentials(
@@ -1520,7 +1532,9 @@ class TestConnection(TestConnectionEndpoint):
                 conn_type="sqlite",
                 host="stored_host",
                 port=1234,
+                login="stored_login",
                 password="existing_password",
+                extra=json.dumps({"api_key": "existing_key"}),
             )
         )
         session.commit()
@@ -1568,6 +1582,7 @@ class TestConnection(TestConnectionEndpoint):
         tested_connection = mock_test.call_args.args[0]
         assert tested_connection.password == expected_password
 
+    @pytest.mark.enable_redact
     @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."""

Reply via email to