Skip to content

Commit 022c9a5

Browse files
committed
fix comments
1 parent 08d224b commit 022c9a5

7 files changed

Lines changed: 246 additions & 0 deletions

File tree

src/mistapi/__api_session.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,8 +1156,10 @@ def _getself(self) -> bool:
11561156
"enable_two_factor",
11571157
"two_factor_verified",
11581158
"no_tracking",
1159+
"oauth_google",
11591160
"password_expiry",
11601161
"password_modified_time",
1162+
"via_sso",
11611163
]:
11621164
setattr(self, key, val)
11631165
if self._show_cli_notif:

src/mistapi/websockets/location.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ class BleAssetsEvents(_MistWebsocket):
3939
Maximum number of reconnect attempts before giving up.
4040
reconnect_backoff : float, default 2.0
4141
Base backoff delay in seconds. Doubles after each failed attempt.
42+
max_reconnect_backoff : float | None, default None
43+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
4244
queue_maxsize : int, default 0
4345
Maximum number of messages buffered in the internal queue for the
4446
``receive()`` generator. ``0`` means unbounded. When set,
@@ -121,6 +123,8 @@ class ConnectedClientsEvents(_MistWebsocket):
121123
Maximum number of reconnect attempts before giving up.
122124
reconnect_backoff : float, default 2.0
123125
Base backoff delay in seconds. Doubles after each failed attempt.
126+
max_reconnect_backoff : float | None, default None
127+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
124128
queue_maxsize : int, default 0
125129
Maximum number of messages buffered in the internal queue for the
126130
``receive()`` generator. ``0`` means unbounded. When set,
@@ -203,6 +207,8 @@ class SdkClientsEvents(_MistWebsocket):
203207
Maximum number of reconnect attempts before giving up.
204208
reconnect_backoff : float, default 2.0
205209
Base backoff delay in seconds. Doubles after each failed attempt.
210+
max_reconnect_backoff : float | None, default None
211+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
206212
queue_maxsize : int, default 0
207213
Maximum number of messages buffered in the internal queue for the
208214
``receive()`` generator. ``0`` means unbounded. When set,
@@ -285,6 +291,8 @@ class UnconnectedClientsEvents(_MistWebsocket):
285291
Maximum number of reconnect attempts before giving up.
286292
reconnect_backoff : float, default 2.0
287293
Base backoff delay in seconds. Doubles after each failed attempt.
294+
max_reconnect_backoff : float | None, default None
295+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
288296
queue_maxsize : int, default 0
289297
Maximum number of messages buffered in the internal queue for the
290298
``receive()`` generator. ``0`` means unbounded. When set,
@@ -369,6 +377,8 @@ class DiscoveredBleAssetsEvents(_MistWebsocket):
369377
Maximum number of reconnect attempts before giving up.
370378
reconnect_backoff : float, default 2.0
371379
Base backoff delay in seconds. Doubles after each failed attempt.
380+
max_reconnect_backoff : float | None, default None
381+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
372382
queue_maxsize : int, default 0
373383
Maximum number of messages buffered in the internal queue for the
374384
``receive()`` generator. ``0`` means unbounded. When set,

src/mistapi/websockets/orgs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class InsightsEvents(_MistWebsocket):
3737
Maximum number of reconnect attempts before giving up.
3838
reconnect_backoff : float, default 2.0
3939
Base backoff delay in seconds. Doubles after each failed attempt.
40+
max_reconnect_backoff : float | None, default None
41+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
4042
queue_maxsize : int, default 0
4143
Maximum number of messages buffered in the internal queue for the
4244
``receive()`` generator. ``0`` means unbounded. When set,
@@ -115,6 +117,8 @@ class MxEdgesStatsEvents(_MistWebsocket):
115117
Maximum number of reconnect attempts before giving up.
116118
reconnect_backoff : float, default 2.0
117119
Base backoff delay in seconds. Doubles after each failed attempt.
120+
max_reconnect_backoff : float | None, default None
121+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
118122
queue_maxsize : int, default 0
119123
Maximum number of messages buffered in the internal queue for the
120124
``receive()`` generator. ``0`` means unbounded. When set,
@@ -193,6 +197,8 @@ class MxEdgesEvents(_MistWebsocket):
193197
Maximum number of reconnect attempts before giving up.
194198
reconnect_backoff : float, default 2.0
195199
Base backoff delay in seconds. Doubles after each failed attempt.
200+
max_reconnect_backoff : float | None, default None
201+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
196202
queue_maxsize : int, default 0
197203
Maximum number of messages buffered in the internal queue for the
198204
``receive()`` generator. ``0`` means unbounded. When set,

src/mistapi/websockets/session.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class SessionWithUrl(_MistWebsocket):
4646
Maximum number of reconnect attempts before giving up.
4747
reconnect_backoff : float, default 2.0
4848
Base backoff delay in seconds. Doubles after each failed attempt.
49+
max_reconnect_backoff : float | None, default None
50+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
4951
queue_maxsize : int, default 0
5052
Maximum number of messages buffered in the internal queue for the
5153
``receive()`` generator. ``0`` means unbounded. When set,

src/mistapi/websockets/sites.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class ClientsStatsEvents(_MistWebsocket):
3737
Maximum number of reconnect attempts before giving up.
3838
reconnect_backoff : float, default 2.0
3939
Base backoff delay in seconds. Doubles after each failed attempt.
40+
max_reconnect_backoff : float | None, default None
41+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
4042
queue_maxsize : int, default 0
4143
Maximum number of messages buffered in the internal queue for the
4244
``receive()`` generator. ``0`` means unbounded. When set,
@@ -124,6 +126,8 @@ class DeviceCmdEvents(_MistWebsocket):
124126
Maximum number of reconnect attempts before giving up.
125127
reconnect_backoff : float, default 2.0
126128
Base backoff delay in seconds. Doubles after each failed attempt.
129+
max_reconnect_backoff : float | None, default None
130+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
127131
queue_maxsize : int, default 0
128132
Maximum number of messages buffered in the internal queue for the
129133
``receive()`` generator. ``0`` means unbounded. When set,
@@ -206,6 +210,8 @@ class DeviceStatsEvents(_MistWebsocket):
206210
Maximum number of reconnect attempts before giving up.
207211
reconnect_backoff : float, default 2.0
208212
Base backoff delay in seconds. Doubles after each failed attempt.
213+
max_reconnect_backoff : float | None, default None
214+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
209215
queue_maxsize : int, default 0
210216
Maximum number of messages buffered in the internal queue for the
211217
``receive()`` generator. ``0`` means unbounded. When set,
@@ -285,6 +291,8 @@ class DeviceEvents(_MistWebsocket):
285291
Maximum number of reconnect attempts before giving up.
286292
reconnect_backoff : float, default 2.0
287293
Base backoff delay in seconds. Doubles after each failed attempt.
294+
max_reconnect_backoff : float | None, default None
295+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
288296
queue_maxsize : int, default 0
289297
Maximum number of messages buffered in the internal queue for the
290298
``receive()`` generator. ``0`` means unbounded. When set,
@@ -364,6 +372,8 @@ class MxEdgesStatsEvents(_MistWebsocket):
364372
Maximum number of reconnect attempts before giving up.
365373
reconnect_backoff : float, default 2.0
366374
Base backoff delay in seconds. Doubles after each failed attempt.
375+
max_reconnect_backoff : float | None, default None
376+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
367377
queue_maxsize : int, default 0
368378
Maximum number of messages buffered in the internal queue for the
369379
``receive()`` generator. ``0`` means unbounded. When set,
@@ -443,6 +453,8 @@ class MxEdgesEvents(_MistWebsocket):
443453
Maximum number of reconnect attempts before giving up.
444454
reconnect_backoff : float, default 2.0
445455
Base backoff delay in seconds. Doubles after each failed attempt.
456+
max_reconnect_backoff : float | None, default None
457+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
446458
queue_maxsize : int, default 0
447459
Maximum number of messages buffered in the internal queue for the
448460
``receive()`` generator. ``0`` means unbounded. When set,
@@ -522,6 +534,8 @@ class PcapEvents(_MistWebsocket):
522534
Maximum number of reconnect attempts before giving up.
523535
reconnect_backoff : float, default 2.0
524536
Base backoff delay in seconds. Doubles after each failed attempt.
537+
max_reconnect_backoff : float | None, default None
538+
Maximum backoff delay in seconds. If None, backoff grows indefinitely.
525539
queue_maxsize : int, default 0
526540
Maximum number of messages buffered in the internal queue for the
527541
``receive()`` generator. ``0`` means unbounded. When set,

tests/unit/test_api_session.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,110 @@ def test_authentication_status_authenticated(self, authenticated_session) -> Non
252252
assert authenticated_session.get_authentication_status()
253253

254254

255+
class TestPasswordClearingAfterLogin:
256+
"""Test that _password is cleared after successful login"""
257+
258+
def test_password_cleared_after_successful_login(self, isolated_session) -> None:
259+
"""_process_login sets _password to None after a 200 response from /api/v1/login"""
260+
# Arrange
261+
isolated_session._cloud_uri = "api.mist.com"
262+
isolated_session.email = "test@example.com"
263+
isolated_session._password = "secret_password"
264+
265+
mock_session = Mock()
266+
mock_resp = Mock()
267+
mock_resp.status_code = 200
268+
mock_session.post.return_value = mock_resp
269+
270+
with patch.object(isolated_session, "_new_session", return_value=mock_session):
271+
# Act
272+
error = isolated_session._process_login(retry=False)
273+
274+
# Assert
275+
assert error is None
276+
assert isolated_session._password is None
277+
278+
def test_password_cleared_after_failed_login(self, isolated_session) -> None:
279+
"""_process_login also clears _password on failure (existing behaviour)"""
280+
# Arrange
281+
isolated_session._cloud_uri = "api.mist.com"
282+
isolated_session.email = "test@example.com"
283+
isolated_session._password = "wrong_password"
284+
285+
mock_session = Mock()
286+
mock_resp = Mock()
287+
mock_resp.status_code = 401
288+
mock_resp.json.return_value = {"detail": "invalid credentials"}
289+
mock_session.post.return_value = mock_resp
290+
291+
with patch.object(isolated_session, "_new_session", return_value=mock_session):
292+
# Act
293+
isolated_session._process_login(retry=False)
294+
295+
# Assert
296+
assert isolated_session._password is None
297+
298+
def test_two_factor_succeeds_after_password_cleared(self, isolated_session) -> None:
299+
"""
300+
login_with_return with two_factor still succeeds after _process_login
301+
clears the password, because _two_factor_authentication only sends the
302+
2FA code (not the password) to /api/v1/login/two_factor.
303+
"""
304+
# Arrange
305+
isolated_session._cloud_uri = "api.mist.com"
306+
isolated_session.email = "test@example.com"
307+
isolated_session._password = "secret_password"
308+
309+
# Mock _new_session
310+
mock_session = Mock()
311+
isolated_session._session = mock_session
312+
313+
# _process_login succeeds (clears password)
314+
login_resp = Mock()
315+
login_resp.status_code = 200
316+
317+
# _two_factor_authentication succeeds
318+
two_factor_resp = Mock()
319+
two_factor_resp.status_code = 200
320+
321+
mock_session.post.side_effect = [login_resp, two_factor_resp]
322+
323+
# mist_get for /api/v1/self after auth
324+
mock_self_resp = Mock()
325+
mock_self_resp.status_code = 200
326+
mock_self_resp.data = {
327+
"id": "user-123",
328+
"email": "test@example.com",
329+
"first_name": "Test",
330+
"last_name": "User",
331+
"privileges": [],
332+
"two_factor_required": False,
333+
"two_factor_passed": True,
334+
"via_sso": False,
335+
"tags": [],
336+
}
337+
338+
with patch.object(isolated_session, "_new_session", return_value=mock_session):
339+
with patch.object(
340+
isolated_session, "mist_get", return_value=mock_self_resp
341+
):
342+
# Act
343+
result = isolated_session.login_with_return(
344+
email="test@example.com",
345+
password="secret_password",
346+
two_factor="123456",
347+
)
348+
349+
# Assert – password was cleared by _process_login
350+
assert isolated_session._password is None
351+
# Assert – 2FA POST was sent to the correct endpoint without password
352+
second_post_call = mock_session.post.call_args_list[1]
353+
assert "/api/v1/login/two_factor" in second_post_call.args[0]
354+
assert second_post_call.kwargs["json"] == {"two_factor": "123456"}
355+
# Assert – overall auth succeeded
356+
assert result["authenticated"] is True
357+
358+
255359
class TestPrivilegeManagement:
256360
"""Test privilege-related functionality"""
257361

tests/unit/test_websocket_client.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,18 @@ def test_negative_queue_maxsize_raises(self, mock_session) -> None:
729729
with pytest.raises(ValueError, match="queue_maxsize must be >= 0"):
730730
_MistWebsocket(mock_session, channels=["/ch"], queue_maxsize=-1)
731731

732+
def test_negative_max_reconnect_backoff_raises(self, mock_session) -> None:
733+
with pytest.raises(ValueError, match="max_reconnect_backoff must be > 0"):
734+
_MistWebsocket(mock_session, channels=["/ch"], max_reconnect_backoff=-1.0)
735+
736+
def test_zero_max_reconnect_backoff_raises(self, mock_session) -> None:
737+
with pytest.raises(ValueError, match="max_reconnect_backoff must be > 0"):
738+
_MistWebsocket(mock_session, channels=["/ch"], max_reconnect_backoff=0)
739+
740+
def test_max_reconnect_backoff_none_allowed(self, mock_session) -> None:
741+
client = _MistWebsocket(mock_session, channels=["/ch"], max_reconnect_backoff=None)
742+
assert client._max_reconnect_backoff is None
743+
732744

733745
# ---------------------------------------------------------------------------
734746
# Public WebSocket channel classes
@@ -987,6 +999,102 @@ def test_no_reconnect_when_disabled(self, mock_session) -> None:
987999
# run_forever called exactly once, no retry
9881000
mock_ws.run_forever.assert_called_once()
9891001

1002+
def test_unlimited_attempts_when_max_is_zero(self, mock_session) -> None:
1003+
"""max_reconnect_attempts=0 means unlimited: loop should not stop on its own."""
1004+
client = self._make_client(mock_session, max_reconnect_attempts=0)
1005+
call_count = 0
1006+
target_calls = 8 # well above any hardcoded limit
1007+
1008+
def fake_run_forever(**kwargs):
1009+
nonlocal call_count
1010+
call_count += 1
1011+
if call_count >= target_calls:
1012+
client._user_disconnect.set() # stop the loop
1013+
client._handle_close(client._ws, 1006, "drop")
1014+
1015+
mock_ws = Mock()
1016+
mock_ws.run_forever.side_effect = fake_run_forever
1017+
with patch.object(client, "_create_ws_app", return_value=mock_ws):
1018+
client._ws = mock_ws
1019+
client._run_forever_safe()
1020+
1021+
assert call_count == target_calls
1022+
1023+
def test_delay_capped_by_max_reconnect_backoff(self, mock_session) -> None:
1024+
"""When max_reconnect_backoff is set, the backoff delay never exceeds it."""
1025+
cap = 0.05
1026+
client = self._make_client(
1027+
mock_session,
1028+
max_reconnect_attempts=5,
1029+
reconnect_backoff=0.01,
1030+
max_reconnect_backoff=cap,
1031+
)
1032+
observed_delays: list[float] = []
1033+
1034+
original_wait = client._user_disconnect.wait
1035+
1036+
def capture_delay(timeout=None):
1037+
if timeout is not None:
1038+
observed_delays.append(timeout)
1039+
return original_wait(timeout=0) # don't actually sleep
1040+
1041+
def fake_run_forever(**kwargs):
1042+
client._handle_close(client._ws, 1006, "drop")
1043+
1044+
mock_ws = Mock()
1045+
mock_ws.run_forever.side_effect = fake_run_forever
1046+
with (
1047+
patch.object(client, "_create_ws_app", return_value=mock_ws),
1048+
patch.object(client._user_disconnect, "wait", side_effect=capture_delay),
1049+
):
1050+
client._ws = mock_ws
1051+
client._run_forever_safe()
1052+
1053+
# Should have recorded a delay for each reconnect attempt
1054+
assert len(observed_delays) > 0
1055+
# Every observed delay must be <= cap
1056+
for delay in observed_delays:
1057+
assert delay <= cap, f"delay {delay} exceeds cap {cap}"
1058+
# Without the cap, later delays would grow via exponential backoff
1059+
# (e.g., 0.01, 0.02, 0.04, 0.08, 0.16). Verify the cap was actually
1060+
# needed by checking that at least one uncapped delay would exceed it.
1061+
uncapped = [0.01 * (2 ** i) for i in range(len(observed_delays))]
1062+
assert any(d > cap for d in uncapped), "cap was never exercised"
1063+
1064+
def test_delay_uncapped_when_max_reconnect_backoff_is_none(self, mock_session) -> None:
1065+
"""Without max_reconnect_backoff, delays grow without bound."""
1066+
client = self._make_client(
1067+
mock_session,
1068+
max_reconnect_attempts=4,
1069+
reconnect_backoff=0.01,
1070+
max_reconnect_backoff=None,
1071+
)
1072+
observed_delays: list[float] = []
1073+
1074+
original_wait = client._user_disconnect.wait
1075+
1076+
def capture_delay(timeout=None):
1077+
if timeout is not None:
1078+
observed_delays.append(timeout)
1079+
return original_wait(timeout=0)
1080+
1081+
def fake_run_forever(**kwargs):
1082+
client._handle_close(client._ws, 1006, "drop")
1083+
1084+
mock_ws = Mock()
1085+
mock_ws.run_forever.side_effect = fake_run_forever
1086+
with (
1087+
patch.object(client, "_create_ws_app", return_value=mock_ws),
1088+
patch.object(client._user_disconnect, "wait", side_effect=capture_delay),
1089+
):
1090+
client._ws = mock_ws
1091+
client._run_forever_safe()
1092+
1093+
assert len(observed_delays) >= 2
1094+
# Each delay should be double the previous (exponential backoff)
1095+
for i in range(1, len(observed_delays)):
1096+
assert observed_delays[i] == pytest.approx(observed_delays[i - 1] * 2)
1097+
9901098

9911099
# ---------------------------------------------------------------------------
9921100
# Callback exception safety

0 commit comments

Comments
 (0)