Skip to content

Commit 9e82118

Browse files
committed
Check server version when opening the connection
1 parent bc9a942 commit 9e82118

File tree

6 files changed

+510
-159
lines changed

6 files changed

+510
-159
lines changed

poetry.lock

Lines changed: 164 additions & 158 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ readme = "README.md"
1010
python = "^3.9"
1111
python-qpid-proton = "^0.39.0"
1212
typing-extensions = "^4.13.0"
13+
packaging = "^23.0"
1314

1415

1516
[tool.poetry.group.dev.dependencies]

rabbitmq_amqp_python_client/connection.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
Union,
1111
)
1212

13+
from packaging import version
14+
1315
import typing_extensions
1416

1517
from .address_helper import validate_address
@@ -166,6 +168,50 @@ def _create_connection(self) -> None:
166168
password=self._password,
167169
)
168170

171+
self._validate_server_properties()
172+
173+
def _validate_server_properties(self) -> None:
174+
"""
175+
Validate the server properties returned in the connection handshake.
176+
177+
Checks that the server is RabbitMQ and the version is >= 4.0.0.
178+
179+
Raises:
180+
ValidationCodeException: If server is not RabbitMQ or version < 4.0.0
181+
"""
182+
if self._conn is None or self._conn.conn is None:
183+
raise ValidationCodeException("Connection not established")
184+
185+
remote_props = self._conn.conn.remote_properties
186+
if remote_props is None:
187+
raise ValidationCodeException("No remote properties received from server")
188+
189+
# Check if server is RabbitMQ
190+
product = remote_props.get("product")
191+
if product != "RabbitMQ":
192+
raise ValidationCodeException(
193+
f"Connection to non-RabbitMQ server detected. "
194+
f"Expected 'RabbitMQ', got '{product}'"
195+
)
196+
197+
# Check server version is >= 4.0.0
198+
server_version = remote_props.get("version")
199+
if server_version is None:
200+
raise ValidationCodeException("Server version not provided")
201+
202+
try:
203+
if version.parse(str(server_version)) < version.parse("4.0.0"):
204+
raise ValidationCodeException(
205+
f"The AMQP client library requires RabbitMQ 4.0.0 or higher. "
206+
f"Server version: {server_version}"
207+
)
208+
except Exception as e:
209+
raise ValidationCodeException(
210+
f"Failed to parse server version '{server_version}': {e}"
211+
)
212+
213+
logger.debug(f"Connected to RabbitMQ server version {server_version}")
214+
169215
def dial(self) -> None:
170216
"""
171217
Establish a connection to the AMQP server.

rabbitmq_amqp_python_client/qpid/proton/_endpoints.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ def remote_desired_capabilities(self):
482482
return c and SymbolList(c)
483483

484484
@property
485-
def remote_properties(self):
485+
def remote_properties(self) -> Optional[Data]:
486486
"""
487487
The properties specified by the remote peer for this connection.
488488

rabbitmq_amqp_python_client/qpid/proton/_utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,11 @@ def __init__(
465465
lambda: not (self.conn.state & Endpoint.REMOTE_UNINIT),
466466
msg="Opening connection",
467467
)
468+
self.wait(
469+
lambda: (self.conn.state & Endpoint.REMOTE_ACTIVE),
470+
timeout=10,
471+
msg="Connection opened",
472+
)
468473

469474
except ConnectionException:
470475
if self.conn is not None:

tests/test_server_validation.py

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
"""
2+
Unit tests for server validation functionality in the Connection class.
3+
These tests use mocks to avoid external dependencies.
4+
5+
To run these tests:
6+
pytest tests/test_server_validation.py -v
7+
8+
Or to run a specific test:
9+
pytest tests/test_server_validation.py::TestServerValidation::test_validate_server_properties_success_exact_version -v
10+
"""
11+
12+
import pytest
13+
from unittest.mock import Mock, MagicMock
14+
15+
from rabbitmq_amqp_python_client.connection import Connection
16+
from rabbitmq_amqp_python_client.exceptions import ValidationCodeException
17+
18+
19+
class TestServerValidation:
20+
"""Test cases for the _validate_server_properties method."""
21+
22+
def setup_method(self):
23+
"""Set up test fixtures."""
24+
self.connection = Connection.__new__(Connection)
25+
# Initialize required attributes that would normally be set in __init__
26+
self.connection._conn = None
27+
self.connection._addr = "amqp://localhost:5672"
28+
self.connection._addrs = None
29+
self.connection._conf_ssl_context = None
30+
self.connection._managements = []
31+
self.connection._recovery_configuration = None
32+
self.connection._ssl_domain = None
33+
self.connection._connections = []
34+
self.connection._index = -1
35+
self.connection._publishers = []
36+
self.connection._consumers = []
37+
self.connection._oauth2_options = None
38+
39+
def test_validate_server_properties_success_exact_version(self):
40+
"""Test successful validation with exact minimum version 4.0.0."""
41+
# Create mock objects
42+
mock_blocking_conn = Mock()
43+
mock_proton_conn = Mock()
44+
mock_remote_props = {
45+
"product": "RabbitMQ",
46+
"version": "4.0.0"
47+
}
48+
49+
mock_proton_conn.remote_properties = mock_remote_props
50+
mock_blocking_conn.conn = mock_proton_conn
51+
self.connection._conn = mock_blocking_conn
52+
53+
# Should not raise any exception
54+
self.connection._validate_server_properties()
55+
56+
def test_validate_server_properties_success_higher_version(self):
57+
"""Test successful validation with version higher than minimum."""
58+
# Create mock objects
59+
mock_blocking_conn = Mock()
60+
mock_proton_conn = Mock()
61+
mock_remote_props = {
62+
"product": "RabbitMQ",
63+
"version": "4.1.2"
64+
}
65+
66+
mock_proton_conn.remote_properties = mock_remote_props
67+
mock_blocking_conn.conn = mock_proton_conn
68+
self.connection._conn = mock_blocking_conn
69+
70+
# Should not raise any exception
71+
self.connection._validate_server_properties()
72+
73+
def test_validate_server_properties_no_connection(self):
74+
"""Test validation fails when connection is None."""
75+
self.connection._conn = None
76+
77+
with pytest.raises(ValidationCodeException) as exc_info:
78+
self.connection._validate_server_properties()
79+
80+
assert "Connection not established" in str(exc_info.value)
81+
82+
def test_validate_server_properties_no_proton_connection(self):
83+
"""Test validation fails when proton connection is None."""
84+
mock_blocking_conn = Mock()
85+
mock_blocking_conn.conn = None
86+
self.connection._conn = mock_blocking_conn
87+
88+
with pytest.raises(ValidationCodeException) as exc_info:
89+
self.connection._validate_server_properties()
90+
91+
assert "Connection not established" in str(exc_info.value)
92+
93+
def test_validate_server_properties_no_remote_properties(self):
94+
"""Test validation fails when remote_properties is None."""
95+
mock_blocking_conn = Mock()
96+
mock_proton_conn = Mock()
97+
mock_proton_conn.remote_properties = None
98+
mock_blocking_conn.conn = mock_proton_conn
99+
self.connection._conn = mock_blocking_conn
100+
101+
with pytest.raises(ValidationCodeException) as exc_info:
102+
self.connection._validate_server_properties()
103+
104+
assert "No remote properties received from server" in str(exc_info.value)
105+
106+
def test_validate_server_properties_wrong_product(self):
107+
"""Test validation fails when server is not RabbitMQ."""
108+
mock_blocking_conn = Mock()
109+
mock_proton_conn = Mock()
110+
mock_remote_props = {
111+
"product": "Apache ActiveMQ",
112+
"version": "4.0.0"
113+
}
114+
115+
mock_proton_conn.remote_properties = mock_remote_props
116+
mock_blocking_conn.conn = mock_proton_conn
117+
self.connection._conn = mock_blocking_conn
118+
119+
with pytest.raises(ValidationCodeException) as exc_info:
120+
self.connection._validate_server_properties()
121+
122+
error_msg = str(exc_info.value)
123+
assert "Connection to non-RabbitMQ server detected" in error_msg
124+
assert "Expected 'RabbitMQ', got 'Apache ActiveMQ'" in error_msg
125+
126+
def test_validate_server_properties_missing_product(self):
127+
"""Test validation fails when product property is missing."""
128+
mock_blocking_conn = Mock()
129+
mock_proton_conn = Mock()
130+
mock_remote_props = {
131+
"version": "4.0.0"
132+
# Missing "product" key
133+
}
134+
135+
mock_proton_conn.remote_properties = mock_remote_props
136+
mock_blocking_conn.conn = mock_proton_conn
137+
self.connection._conn = mock_blocking_conn
138+
139+
with pytest.raises(ValidationCodeException) as exc_info:
140+
self.connection._validate_server_properties()
141+
142+
error_msg = str(exc_info.value)
143+
assert "Connection to non-RabbitMQ server detected" in error_msg
144+
assert "Expected 'RabbitMQ', got 'None'" in error_msg
145+
146+
def test_validate_server_properties_missing_version(self):
147+
"""Test validation fails when version property is missing."""
148+
mock_blocking_conn = Mock()
149+
mock_proton_conn = Mock()
150+
mock_remote_props = {
151+
"product": "RabbitMQ"
152+
# Missing "version" key
153+
}
154+
155+
mock_proton_conn.remote_properties = mock_remote_props
156+
mock_blocking_conn.conn = mock_proton_conn
157+
self.connection._conn = mock_blocking_conn
158+
159+
with pytest.raises(ValidationCodeException) as exc_info:
160+
self.connection._validate_server_properties()
161+
162+
assert "Server version not provided" in str(exc_info.value)
163+
164+
def test_validate_server_properties_version_too_low(self):
165+
"""Test validation fails when server version is below 4.0.0."""
166+
test_cases = [
167+
"3.9.9",
168+
"3.12.0",
169+
"2.8.7",
170+
"1.0.0"
171+
]
172+
173+
for version_str in test_cases:
174+
mock_blocking_conn = Mock()
175+
mock_proton_conn = Mock()
176+
mock_remote_props = {
177+
"product": "RabbitMQ",
178+
"version": version_str
179+
}
180+
181+
mock_proton_conn.remote_properties = mock_remote_props
182+
mock_blocking_conn.conn = mock_proton_conn
183+
self.connection._conn = mock_blocking_conn
184+
185+
with pytest.raises(ValidationCodeException) as exc_info:
186+
self.connection._validate_server_properties()
187+
188+
error_msg = str(exc_info.value)
189+
assert "The AMQP client library requires RabbitMQ 4.0.0 or higher" in error_msg
190+
assert f"Server version: {version_str}" in error_msg
191+
192+
def test_validate_server_properties_valid_higher_versions(self):
193+
"""Test validation succeeds with various higher versions."""
194+
valid_versions = [
195+
"4.0.0",
196+
"4.0.1",
197+
"4.1.0",
198+
"4.10.15",
199+
"5.0.0",
200+
"10.2.3",
201+
"v4.0.0", # v prefix should be stripped and accepted
202+
"4.0.0.0.0" # Extra zeroes should be normalized and accepted
203+
]
204+
205+
for version_str in valid_versions:
206+
mock_blocking_conn = Mock()
207+
mock_proton_conn = Mock()
208+
mock_remote_props = {
209+
"product": "RabbitMQ",
210+
"version": version_str
211+
}
212+
213+
mock_proton_conn.remote_properties = mock_remote_props
214+
mock_blocking_conn.conn = mock_proton_conn
215+
self.connection._conn = mock_blocking_conn
216+
217+
# Should not raise any exception
218+
self.connection._validate_server_properties()
219+
220+
def test_validate_server_properties_invalid_version_format(self):
221+
"""Test validation handles invalid version formats gracefully."""
222+
invalid_versions = [
223+
"invalid-version",
224+
"4.0.0-alpha", # Pre-release, should be rejected
225+
"",
226+
]
227+
228+
for version_str in invalid_versions:
229+
mock_blocking_conn = Mock()
230+
mock_proton_conn = Mock()
231+
mock_remote_props = {
232+
"product": "RabbitMQ",
233+
"version": version_str
234+
}
235+
236+
mock_proton_conn.remote_properties = mock_remote_props
237+
mock_blocking_conn.conn = mock_proton_conn
238+
self.connection._conn = mock_blocking_conn
239+
240+
with pytest.raises(ValidationCodeException) as exc_info:
241+
self.connection._validate_server_properties()
242+
243+
error_msg = str(exc_info.value)
244+
# Should either be version too low or parsing error
245+
assert ("Failed to parse server version" in error_msg or
246+
"requires RabbitMQ 4.0.0 or higher" in error_msg)
247+
248+
def test_validate_server_properties_version_edge_cases(self):
249+
"""Test validation with edge case version values."""
250+
# Test with pre-release versions that should still work
251+
edge_case_versions = [
252+
"4.0.0-rc1", # This might work depending on packaging library
253+
"4.0.0b1", # Beta version
254+
]
255+
256+
for version_str in edge_case_versions:
257+
mock_blocking_conn = Mock()
258+
mock_proton_conn = Mock()
259+
mock_remote_props = {
260+
"product": "RabbitMQ",
261+
"version": version_str
262+
}
263+
264+
mock_proton_conn.remote_properties = mock_remote_props
265+
mock_blocking_conn.conn = mock_proton_conn
266+
self.connection._conn = mock_blocking_conn
267+
268+
try:
269+
# Depending on packaging library behavior, this might pass or fail
270+
# The important thing is that it doesn't crash
271+
self.connection._validate_server_properties()
272+
except ValidationCodeException:
273+
# This is acceptable for edge cases
274+
pass
275+
276+
def test_validate_server_properties_with_additional_properties(self):
277+
"""Test validation works when remote_properties has additional fields."""
278+
mock_blocking_conn = Mock()
279+
mock_proton_conn = Mock()
280+
mock_remote_props = {
281+
"product": "RabbitMQ",
282+
"version": "4.2.1",
283+
"platform": "Linux",
284+
"information": "Licensed under the MPL 2.0",
285+
"copyright": "Copyright (c) 2007-2023 VMware, Inc. or its affiliates."
286+
}
287+
288+
mock_proton_conn.remote_properties = mock_remote_props
289+
mock_blocking_conn.conn = mock_proton_conn
290+
self.connection._conn = mock_blocking_conn
291+
292+
# Should not raise any exception despite additional properties
293+
self.connection._validate_server_properties()

0 commit comments

Comments
 (0)