|
26 | 26 | import pytest |
27 | 27 | from flask import g |
28 | 28 | from flask_appbuilder.const import AUTH_DB, AUTH_LDAP |
| 29 | +from sqlalchemy.exc import OperationalError, PendingRollbackError |
29 | 30 |
|
30 | 31 | from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX |
31 | 32 | from airflow.api_fastapi.common.types import MenuItem |
@@ -959,6 +960,67 @@ def test_resetdb( |
959 | 960 | mock_init.assert_called_once() |
960 | 961 |
|
961 | 962 |
|
| 963 | +@pytest.mark.db_test |
| 964 | +class TestDeserializeUserSessionCleanup: |
| 965 | + """Test that deserialize_user cleans up the FAB scoped session on database errors. |
| 966 | +
|
| 967 | + Problem: |
| 968 | + When the database connection drops (e.g., PostgreSQL's |
| 969 | + ``idle_in_transaction_session_timeout`` fires), the underlying connection |
| 970 | + becomes invalid. SQLAlchemy raises ``OperationalError`` on the first request |
| 971 | + that hits the dead connection. The scoped session then enters an invalid |
| 972 | + state. Any subsequent request that reuses the same thread-local session |
| 973 | + raises ``PendingRollbackError`` — permanently breaking the API server until |
| 974 | + it is restarted. |
| 975 | + """ |
| 976 | + |
| 977 | + @staticmethod |
| 978 | + def _patched_session(auth_manager, mock_session): |
| 979 | + """Replace the ``session`` property on *auth_manager* with *mock_session*.""" |
| 980 | + return mock.patch.object( |
| 981 | + type(auth_manager), "session", new_callable=mock.PropertyMock, return_value=mock_session |
| 982 | + ) |
| 983 | + |
| 984 | + @pytest.mark.parametrize( |
| 985 | + "raised_exc", |
| 986 | + [ |
| 987 | + OperationalError("server closed the connection unexpectedly", None, Exception()), |
| 988 | + PendingRollbackError( |
| 989 | + "Can't reconnect until invalid transaction is rolled back. " |
| 990 | + "Please rollback() fully before proceeding" |
| 991 | + ), |
| 992 | + ], |
| 993 | + ids=["operational_error", "pending_rollback_error"], |
| 994 | + ) |
| 995 | + def test_db_error_calls_session_remove(self, auth_manager_with_appbuilder, raised_exc): |
| 996 | + """session.remove() is called on SQLAlchemy errors so the next request recovers.""" |
| 997 | + mock_session = MagicMock(spec=["scalars", "remove"]) |
| 998 | + mock_session.scalars.side_effect = raised_exc |
| 999 | + auth_manager_with_appbuilder.cache.pop(99997, None) |
| 1000 | + |
| 1001 | + with self._patched_session(auth_manager_with_appbuilder, mock_session): |
| 1002 | + with pytest.raises(type(raised_exc)): |
| 1003 | + auth_manager_with_appbuilder.deserialize_user({"sub": "99997"}) |
| 1004 | + |
| 1005 | + mock_session.remove.assert_called_once() |
| 1006 | + |
| 1007 | + def test_db_error_propagates_when_session_remove_raises(self, auth_manager_with_appbuilder): |
| 1008 | + """The original SQLAlchemyError propagates even if session.remove() itself raises.""" |
| 1009 | + # Arrange — session.scalars raises the original DB error; |
| 1010 | + # session.remove raises a secondary error that must be suppressed. |
| 1011 | + original_exc = OperationalError("connection dropped", None, Exception()) |
| 1012 | + mock_session = MagicMock(spec=["scalars", "remove"]) |
| 1013 | + mock_session.scalars.side_effect = original_exc |
| 1014 | + mock_session.remove.side_effect = AttributeError("appbuilder gone") |
| 1015 | + auth_manager_with_appbuilder.cache.pop(99997, None) |
| 1016 | + |
| 1017 | + with self._patched_session(auth_manager_with_appbuilder, mock_session): |
| 1018 | + with pytest.raises(OperationalError): |
| 1019 | + auth_manager_with_appbuilder.deserialize_user({"sub": "99997"}) |
| 1020 | + |
| 1021 | + mock_session.remove.assert_called_once() |
| 1022 | + |
| 1023 | + |
962 | 1024 | class TestFabAuthManagerSessionCleanup: |
963 | 1025 | """Test session cleanup middleware in FAB auth manager FastAPI app. |
964 | 1026 |
|
|
0 commit comments