Skip to content

Commit 3143443

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

File tree

6 files changed

+492
-159
lines changed

6 files changed

+492
-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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212

1313
import typing_extensions
14+
from packaging import version
1415

1516
from .address_helper import validate_address
1617
from .consumer import Consumer
@@ -166,6 +167,50 @@ def _create_connection(self) -> None:
166167
password=self._password,
167168
)
168169

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

0 commit comments

Comments
 (0)