From 08d224bb407ca56472c521679e3dc2dda0a2e128 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Wed, 1 Apr 2026 10:28:51 -0700 Subject: [PATCH 1/4] 0.61.4 --- .gitmodules | 2 +- CHANGELOG.md | 52 ++++++++++++++++++++ pyproject.toml | 2 +- src/mistapi/__api_session.py | 68 ++++++++++++++++++++++++--- src/mistapi/__version.py | 2 +- src/mistapi/api/v1/sites/sle.py | 4 +- src/mistapi/websockets/__ws_client.py | 36 +++++++++----- src/mistapi/websockets/location.py | 10 ++++ src/mistapi/websockets/orgs.py | 6 +++ src/mistapi/websockets/session.py | 2 + src/mistapi/websockets/sites.py | 14 ++++++ tests/unit/test_websocket_client.py | 4 +- uv.lock | 2 +- 13 files changed, 178 insertions(+), 26 deletions(-) diff --git a/.gitmodules b/.gitmodules index 42b124e..081acbd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "mist_openapi"] path = mist_openapi url = https://github.com/mistsys/mist_openapi.git - branch = 2602.1.8 \ No newline at end of file + branch = master \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7696f45..2a09836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,56 @@ # CHANGELOG +## Version 0.61.4 (April 2026) + +**Released**: April 1, 2026 + +This release improves WebSocket reconnection, hardens credential handling, and fixes the two-factor authentication flow. + +--- + +### 1. NEW FEATURES + +#### **Capped Reconnect Backoff (`max_reconnect_backoff`)** +The `_MistWebsocket` client now supports a `max_reconnect_backoff` parameter to cap the exponential backoff delay during reconnection attempts: + +```python +ws = mistapi.websockets.sites.DeviceStatsEvents( + apisession, + site_ids=[""], + auto_reconnect=True, + max_reconnect_backoff=60.0 # Cap backoff at 60 seconds +) +``` + +#### **Unlimited Reconnect Attempts** +Setting `max_reconnect_attempts=0` now enables unlimited reconnection attempts: + +```python +ws = mistapi.websockets.sites.DeviceStatsEvents( + apisession, + site_ids=[""], + auto_reconnect=True, + max_reconnect_attempts=0 # Reconnect indefinitely +) +``` + +--- + +### 2. IMPROVEMENTS + +#### **Credential Override Logging** +`APISession` now logs INFO-level messages when credentials (host, email, password, API token) are overridden by: +- Constructor parameters overriding environment variables +- Vault secrets overriding previously loaded values +- Keyring credentials overriding previously loaded values + +#### **Security: Password Cleared After Login** +The stored password is now cleared from memory immediately after successful login authentication. + +#### **User Attribute Handling** +`_get_self()` now only sets known user attributes (`first_name`, `last_name`, `email`, `enable_two_factor`, `two_factor_verified`, `no_tracking`, `password_expiry`, `password_modified_time`) instead of setting arbitrary response keys as object attributes. + +--- + ## Version 0.61.3 (March 2026) **Released**: March 18, 2026 diff --git a/pyproject.toml b/pyproject.toml index 96c24fb..7e5dc80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mistapi" -version = "0.61.3" +version = "0.61.4" authors = [{ name = "Thomas Munzer", email = "tmunzer@juniper.net" }] description = "Python package to simplify the Mist System APIs usage" keywords = ["Mist", "Juniper", "API"] diff --git a/src/mistapi/__api_session.py b/src/mistapi/__api_session.py index d984200..9e5e505 100644 --- a/src/mistapi/__api_session.py +++ b/src/mistapi/__api_session.py @@ -157,12 +157,28 @@ def __init__( self._session.proxies.update(filtered_proxies) if host: + if self._cloud_uri: + LOGGER.info( + "apisession:__init__: overriding previously loaded MIST_HOST with constructor parameter" + ) self.set_cloud(host) if email: + if self.email: + LOGGER.info( + "apisession:__init__: overriding previously loaded MIST_USER with constructor parameter" + ) self.set_email(email) if password: + if self._password: + LOGGER.info( + "apisession:__init__: overriding previously loaded MIST_PASSWORD with constructor parameter" + ) self.set_password(password) if apitoken: + if self._apitoken: + LOGGER.info( + "apisession:__init__: overriding previously loaded MIST_APITOKEN with constructor parameter" + ) self.set_api_token(apitoken) self.first_name: str = "" self.last_name: str = "" @@ -244,10 +260,18 @@ def _load_vault( mist_host = read_response["data"]["data"].get("MIST_HOST", None) LOGGER.info("apisession:_load_vault: MIST_HOST=%s", mist_host) if mist_host: + if self._cloud_uri: + LOGGER.info( + "apisession:_load_vault: overriding previously loaded MIST_HOST" + ) self.set_cloud(mist_host) mist_apitoken = read_response["data"]["data"].get("MIST_APITOKEN", None) if mist_apitoken: + if self._apitoken: + LOGGER.info( + "apisession:_load_vault: overriding previously loaded MIST_APITOKEN" + ) self.set_api_token(mist_apitoken) except (KeyError, TypeError, AttributeError): LOGGER.error("apisession:_load_vault: Failed to retrieve secret") @@ -270,10 +294,18 @@ def _load_keyring(self, keyring_service) -> None: try: mist_host = keyring.get_password(keyring_service, "MIST_HOST") if mist_host: + if self._cloud_uri: + LOGGER.info( + "apisession:_load_keyring: overriding previously loaded MIST_HOST" + ) LOGGER.info("apisession:_load_keyring: MIST_HOST=%s", mist_host) self.set_cloud(mist_host) mist_apitoken = keyring.get_password(keyring_service, "MIST_APITOKEN") if mist_apitoken: + if self._apitoken: + LOGGER.info( + "apisession:_load_keyring: overriding previously loaded MIST_APITOKEN" + ) if isinstance(mist_apitoken, str): for token in mist_apitoken.split(","): token = token.strip() @@ -285,10 +317,18 @@ def _load_keyring(self, keyring_service) -> None: self.set_api_token(mist_apitoken) mist_user = keyring.get_password(keyring_service, "MIST_USER") if mist_user: + if self.email: + LOGGER.info( + "apisession:_load_keyring: overriding previously loaded MIST_USER" + ) LOGGER.info("apisession:_load_keyring: MIST_USER retrieved") self.set_email(mist_user) mist_password = keyring.get_password(keyring_service, "MIST_PASSWORD") if mist_password: + if self._password: + LOGGER.info( + "apisession:_load_keyring: overriding previously loaded MIST_PASSWORD" + ) LOGGER.info("apisession:_load_keyring: MIST_PASSWORD retrieved") self.set_password(mist_password) except Exception as e: @@ -701,6 +741,7 @@ def _process_login(self, retry: bool = True) -> str | None: if resp.status_code == 200: LOGGER.info("apisession:_process_login:authentication successful!") CONSOLE.info("Authentication successful!") + self._password = None self._set_authenticated(True) else: error = resp.json().get("detail") @@ -818,14 +859,22 @@ def login_with_return( elif self.email and self._password: if two_factor: LOGGER.debug("apisession:login_with_return:login/pwd provided with 2FA") - if self._two_factor_authentication(two_factor): + error_login = self._process_login(retry=False) + if error_login: + LOGGER.error( + "apisession:login_with_return:login/pwd auth failed: %s", + error_login, + ) + return {"authenticated": False, "error": error_login} + if not self._two_factor_authentication(two_factor): LOGGER.error( - "apisession:login_with_return:login/pwd auth failed: 2FA authentication failed" + "apisession:login_with_return:2FA authentication failed" ) return { "authenticated": False, "error": "2FA authentication failed", } + LOGGER.info("apisession:login_with_return:authenticated with 2FA") else: LOGGER.debug("apisession:login_with_return:login/pwd provided w/o 2FA") error_login = self._process_login(retry=False) @@ -1044,10 +1093,8 @@ def _two_factor_authentication(self, two_factor: str) -> bool: True if authentication succeed, False otherwise """ LOGGER.debug("apisession:_two_factor_authentication") - uri = "/api/v1/login" + uri = "/api/v1/login/two_factor" body = { - "email": self.email, - "password": self._password, "two_factor": two_factor, } resp = self._session.post(self._url(uri), json=body) @@ -1102,7 +1149,16 @@ def _getself(self) -> bool: elif key == "tags": for tag in resp.data["tags"]: self.tags.append(tag) - else: + elif key in [ + "first_name", + "last_name", + "email", + "enable_two_factor", + "two_factor_verified", + "no_tracking", + "password_expiry", + "password_modified_time", + ]: setattr(self, key, val) if self._show_cli_notif: print() diff --git a/src/mistapi/__version.py b/src/mistapi/__version.py index 34f48b4..027a7b0 100644 --- a/src/mistapi/__version.py +++ b/src/mistapi/__version.py @@ -1,2 +1,2 @@ -__version__ = "0.61.3" +__version__ = "0.61.4" __author__ = "Thomas Munzer " diff --git a/src/mistapi/api/v1/sites/sle.py b/src/mistapi/api/v1/sites/sle.py index dc5cfa7..4e49d71 100644 --- a/src/mistapi/api/v1/sites/sle.py +++ b/src/mistapi/api/v1/sites/sle.py @@ -18,7 +18,7 @@ @deprecation.deprecated( deprecated_in="0.59.2", removed_in="0.65.0", - current_version="0.61.3", + current_version="0.61.4", details="function replaced with getSiteSleClassifierSummaryTrend", ) def getSiteSleClassifierDetails( @@ -690,7 +690,7 @@ def listSiteSleImpactedWirelessClients( @deprecation.deprecated( deprecated_in="0.59.2", removed_in="0.65.0", - current_version="0.61.3", + current_version="0.61.4", details="function replaced with getSiteSleSummaryTrend", ) def getSiteSleSummary( diff --git a/src/mistapi/websockets/__ws_client.py b/src/mistapi/websockets/__ws_client.py index 59033eb..e351c5d 100644 --- a/src/mistapi/websockets/__ws_client.py +++ b/src/mistapi/websockets/__ws_client.py @@ -69,12 +69,15 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: if max_reconnect_attempts < 0: - raise ValueError("max_reconnect_attempts must be >= 0") + raise ValueError("max_reconnect_attempts must be >= 0 (0 = unlimited)") if reconnect_backoff <= 0: raise ValueError("reconnect_backoff must be > 0") + if max_reconnect_backoff is not None and max_reconnect_backoff <= 0: + raise ValueError("max_reconnect_backoff must be > 0") if queue_maxsize < 0: raise ValueError("queue_maxsize must be >= 0") @@ -85,6 +88,7 @@ def __init__( self._auto_reconnect = auto_reconnect self._max_reconnect_attempts = max_reconnect_attempts self._reconnect_backoff = reconnect_backoff + self._max_reconnect_backoff = max_reconnect_backoff self._lock = threading.Lock() self._ws: websocket.WebSocketApp | None = None self._thread: threading.Thread | None = None @@ -305,22 +309,32 @@ def _run_forever_safe(self) -> None: break self._reconnect_attempts += 1 - if self._reconnect_attempts > self._max_reconnect_attempts: + if ( + self._max_reconnect_attempts > 0 + and self._reconnect_attempts > self._max_reconnect_attempts + ): logger.warning( "Max reconnect attempts (%d) reached, giving up", self._max_reconnect_attempts, ) break - delay = self._reconnect_backoff * ( - 2 ** (self._reconnect_attempts - 1) - ) - logger.info( - "Reconnecting in %.1fs (attempt %d/%d)", - delay, - self._reconnect_attempts, - self._max_reconnect_attempts, - ) + delay = self._reconnect_backoff * (2 ** (self._reconnect_attempts - 1)) + if self._max_reconnect_backoff is not None: + delay = min(delay, self._max_reconnect_backoff) + if self._max_reconnect_attempts > 0: + logger.info( + "Reconnecting in %.1fs (attempt %d/%d)", + delay, + self._reconnect_attempts, + self._max_reconnect_attempts, + ) + else: + logger.info( + "Reconnecting in %.1fs (attempt %d, unlimited)", + delay, + self._reconnect_attempts, + ) if self._user_disconnect.wait(timeout=delay): break # disconnect() called during backoff diff --git a/src/mistapi/websockets/location.py b/src/mistapi/websockets/location.py index 8bb01e6..244bc8e 100644 --- a/src/mistapi/websockets/location.py +++ b/src/mistapi/websockets/location.py @@ -80,6 +80,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/stats/maps/{mid}/assets" for mid in map_ids] @@ -91,6 +92,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -160,6 +162,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/stats/maps/{mid}/clients" for mid in map_ids] @@ -171,6 +174,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -240,6 +244,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/stats/maps/{mid}/sdkclients" for mid in map_ids] @@ -251,6 +256,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -320,6 +326,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [ @@ -333,6 +340,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -402,6 +410,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [ @@ -415,5 +424,6 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) diff --git a/src/mistapi/websockets/orgs.py b/src/mistapi/websockets/orgs.py index 302d1c1..2ccec0e 100644 --- a/src/mistapi/websockets/orgs.py +++ b/src/mistapi/websockets/orgs.py @@ -77,6 +77,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: super().__init__( @@ -87,6 +88,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -153,6 +155,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: super().__init__( @@ -163,6 +166,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -229,6 +233,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: super().__init__( @@ -239,5 +244,6 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) diff --git a/src/mistapi/websockets/session.py b/src/mistapi/websockets/session.py index 1d1f061..11b945a 100644 --- a/src/mistapi/websockets/session.py +++ b/src/mistapi/websockets/session.py @@ -86,6 +86,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: parsed = urlparse(url) @@ -100,6 +101,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) diff --git a/src/mistapi/websockets/sites.py b/src/mistapi/websockets/sites.py index 4011f6a..4e09e18 100644 --- a/src/mistapi/websockets/sites.py +++ b/src/mistapi/websockets/sites.py @@ -77,6 +77,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/stats/clients" for site_id in site_ids] @@ -88,6 +89,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -163,6 +165,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [ @@ -176,6 +179,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -242,6 +246,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/stats/devices" for site_id in site_ids] @@ -253,6 +258,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -319,6 +325,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/devices" for site_id in site_ids] @@ -330,6 +337,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -396,6 +404,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/stats/mxedges" for site_id in site_ids] @@ -407,6 +416,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -473,6 +483,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/mxedges" for site_id in site_ids] @@ -484,6 +495,7 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) @@ -550,6 +562,7 @@ def __init__( auto_reconnect: bool = False, max_reconnect_attempts: int = 5, reconnect_backoff: float = 2.0, + max_reconnect_backoff: float | None = None, queue_maxsize: int = 0, ) -> None: channels = [f"/sites/{site_id}/pcaps"] @@ -561,5 +574,6 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, reconnect_backoff=reconnect_backoff, + max_reconnect_backoff=max_reconnect_backoff, queue_maxsize=queue_maxsize, ) diff --git a/tests/unit/test_websocket_client.py b/tests/unit/test_websocket_client.py index b6c18ca..4b4753c 100644 --- a/tests/unit/test_websocket_client.py +++ b/tests/unit/test_websocket_client.py @@ -1254,9 +1254,7 @@ def test_reconnect_after_disconnect(self, mock_ws_cls, mock_session) -> None: class TestReceiveFinishedExit: """Verify receive() exits when _finished is set even without a sentinel.""" - def test_receive_exits_when_finished_set_without_sentinel( - self, ws_client - ) -> None: + def test_receive_exits_when_finished_set_without_sentinel(self, ws_client) -> None: """Simulates a BaseException scenario where sentinel is never queued.""" ws_client._connected.set() # Simulate: thread died, _finished set, _connected still set, no sentinel diff --git a/uv.lock b/uv.lock index 8eea253..5abde4b 100644 --- a/uv.lock +++ b/uv.lock @@ -567,7 +567,7 @@ wheels = [ [[package]] name = "mistapi" -version = "0.61.3" +version = "0.61.4" source = { editable = "." } dependencies = [ { name = "deprecation" }, From 022c9a522014a6a06ad3336b02df60fc2dcfe7ed Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Wed, 1 Apr 2026 10:43:25 -0700 Subject: [PATCH 2/4] fix comments --- src/mistapi/__api_session.py | 2 + src/mistapi/websockets/location.py | 10 +++ src/mistapi/websockets/orgs.py | 6 ++ src/mistapi/websockets/session.py | 2 + src/mistapi/websockets/sites.py | 14 ++++ tests/unit/test_api_session.py | 104 +++++++++++++++++++++++++++ tests/unit/test_websocket_client.py | 108 ++++++++++++++++++++++++++++ 7 files changed, 246 insertions(+) diff --git a/src/mistapi/__api_session.py b/src/mistapi/__api_session.py index 9e5e505..a5a83b9 100644 --- a/src/mistapi/__api_session.py +++ b/src/mistapi/__api_session.py @@ -1156,8 +1156,10 @@ def _getself(self) -> bool: "enable_two_factor", "two_factor_verified", "no_tracking", + "oauth_google", "password_expiry", "password_modified_time", + "via_sso", ]: setattr(self, key, val) if self._show_cli_notif: diff --git a/src/mistapi/websockets/location.py b/src/mistapi/websockets/location.py index 244bc8e..ad2773c 100644 --- a/src/mistapi/websockets/location.py +++ b/src/mistapi/websockets/location.py @@ -39,6 +39,8 @@ class BleAssetsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -121,6 +123,8 @@ class ConnectedClientsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -203,6 +207,8 @@ class SdkClientsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -285,6 +291,8 @@ class UnconnectedClientsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -369,6 +377,8 @@ class DiscoveredBleAssetsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, diff --git a/src/mistapi/websockets/orgs.py b/src/mistapi/websockets/orgs.py index 2ccec0e..9a04e7f 100644 --- a/src/mistapi/websockets/orgs.py +++ b/src/mistapi/websockets/orgs.py @@ -37,6 +37,8 @@ class InsightsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -115,6 +117,8 @@ class MxEdgesStatsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -193,6 +197,8 @@ class MxEdgesEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, diff --git a/src/mistapi/websockets/session.py b/src/mistapi/websockets/session.py index 11b945a..e7365dc 100644 --- a/src/mistapi/websockets/session.py +++ b/src/mistapi/websockets/session.py @@ -46,6 +46,8 @@ class SessionWithUrl(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, diff --git a/src/mistapi/websockets/sites.py b/src/mistapi/websockets/sites.py index 4e09e18..64b2b88 100644 --- a/src/mistapi/websockets/sites.py +++ b/src/mistapi/websockets/sites.py @@ -37,6 +37,8 @@ class ClientsStatsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -124,6 +126,8 @@ class DeviceCmdEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -206,6 +210,8 @@ class DeviceStatsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -285,6 +291,8 @@ class DeviceEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -364,6 +372,8 @@ class MxEdgesStatsEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -443,6 +453,8 @@ class MxEdgesEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, @@ -522,6 +534,8 @@ class PcapEvents(_MistWebsocket): Maximum number of reconnect attempts before giving up. reconnect_backoff : float, default 2.0 Base backoff delay in seconds. Doubles after each failed attempt. + max_reconnect_backoff : float | None, default None + Maximum backoff delay in seconds. If None, backoff grows indefinitely. queue_maxsize : int, default 0 Maximum number of messages buffered in the internal queue for the ``receive()`` generator. ``0`` means unbounded. When set, diff --git a/tests/unit/test_api_session.py b/tests/unit/test_api_session.py index e28076c..06dd694 100644 --- a/tests/unit/test_api_session.py +++ b/tests/unit/test_api_session.py @@ -252,6 +252,110 @@ def test_authentication_status_authenticated(self, authenticated_session) -> Non assert authenticated_session.get_authentication_status() +class TestPasswordClearingAfterLogin: + """Test that _password is cleared after successful login""" + + def test_password_cleared_after_successful_login(self, isolated_session) -> None: + """_process_login sets _password to None after a 200 response from /api/v1/login""" + # Arrange + isolated_session._cloud_uri = "api.mist.com" + isolated_session.email = "test@example.com" + isolated_session._password = "secret_password" + + mock_session = Mock() + mock_resp = Mock() + mock_resp.status_code = 200 + mock_session.post.return_value = mock_resp + + with patch.object(isolated_session, "_new_session", return_value=mock_session): + # Act + error = isolated_session._process_login(retry=False) + + # Assert + assert error is None + assert isolated_session._password is None + + def test_password_cleared_after_failed_login(self, isolated_session) -> None: + """_process_login also clears _password on failure (existing behaviour)""" + # Arrange + isolated_session._cloud_uri = "api.mist.com" + isolated_session.email = "test@example.com" + isolated_session._password = "wrong_password" + + mock_session = Mock() + mock_resp = Mock() + mock_resp.status_code = 401 + mock_resp.json.return_value = {"detail": "invalid credentials"} + mock_session.post.return_value = mock_resp + + with patch.object(isolated_session, "_new_session", return_value=mock_session): + # Act + isolated_session._process_login(retry=False) + + # Assert + assert isolated_session._password is None + + def test_two_factor_succeeds_after_password_cleared(self, isolated_session) -> None: + """ + login_with_return with two_factor still succeeds after _process_login + clears the password, because _two_factor_authentication only sends the + 2FA code (not the password) to /api/v1/login/two_factor. + """ + # Arrange + isolated_session._cloud_uri = "api.mist.com" + isolated_session.email = "test@example.com" + isolated_session._password = "secret_password" + + # Mock _new_session + mock_session = Mock() + isolated_session._session = mock_session + + # _process_login succeeds (clears password) + login_resp = Mock() + login_resp.status_code = 200 + + # _two_factor_authentication succeeds + two_factor_resp = Mock() + two_factor_resp.status_code = 200 + + mock_session.post.side_effect = [login_resp, two_factor_resp] + + # mist_get for /api/v1/self after auth + mock_self_resp = Mock() + mock_self_resp.status_code = 200 + mock_self_resp.data = { + "id": "user-123", + "email": "test@example.com", + "first_name": "Test", + "last_name": "User", + "privileges": [], + "two_factor_required": False, + "two_factor_passed": True, + "via_sso": False, + "tags": [], + } + + with patch.object(isolated_session, "_new_session", return_value=mock_session): + with patch.object( + isolated_session, "mist_get", return_value=mock_self_resp + ): + # Act + result = isolated_session.login_with_return( + email="test@example.com", + password="secret_password", + two_factor="123456", + ) + + # Assert – password was cleared by _process_login + assert isolated_session._password is None + # Assert – 2FA POST was sent to the correct endpoint without password + second_post_call = mock_session.post.call_args_list[1] + assert "/api/v1/login/two_factor" in second_post_call.args[0] + assert second_post_call.kwargs["json"] == {"two_factor": "123456"} + # Assert – overall auth succeeded + assert result["authenticated"] is True + + class TestPrivilegeManagement: """Test privilege-related functionality""" diff --git a/tests/unit/test_websocket_client.py b/tests/unit/test_websocket_client.py index 4b4753c..b59da14 100644 --- a/tests/unit/test_websocket_client.py +++ b/tests/unit/test_websocket_client.py @@ -729,6 +729,18 @@ def test_negative_queue_maxsize_raises(self, mock_session) -> None: with pytest.raises(ValueError, match="queue_maxsize must be >= 0"): _MistWebsocket(mock_session, channels=["/ch"], queue_maxsize=-1) + def test_negative_max_reconnect_backoff_raises(self, mock_session) -> None: + with pytest.raises(ValueError, match="max_reconnect_backoff must be > 0"): + _MistWebsocket(mock_session, channels=["/ch"], max_reconnect_backoff=-1.0) + + def test_zero_max_reconnect_backoff_raises(self, mock_session) -> None: + with pytest.raises(ValueError, match="max_reconnect_backoff must be > 0"): + _MistWebsocket(mock_session, channels=["/ch"], max_reconnect_backoff=0) + + def test_max_reconnect_backoff_none_allowed(self, mock_session) -> None: + client = _MistWebsocket(mock_session, channels=["/ch"], max_reconnect_backoff=None) + assert client._max_reconnect_backoff is None + # --------------------------------------------------------------------------- # Public WebSocket channel classes @@ -987,6 +999,102 @@ def test_no_reconnect_when_disabled(self, mock_session) -> None: # run_forever called exactly once, no retry mock_ws.run_forever.assert_called_once() + def test_unlimited_attempts_when_max_is_zero(self, mock_session) -> None: + """max_reconnect_attempts=0 means unlimited: loop should not stop on its own.""" + client = self._make_client(mock_session, max_reconnect_attempts=0) + call_count = 0 + target_calls = 8 # well above any hardcoded limit + + def fake_run_forever(**kwargs): + nonlocal call_count + call_count += 1 + if call_count >= target_calls: + client._user_disconnect.set() # stop the loop + client._handle_close(client._ws, 1006, "drop") + + mock_ws = Mock() + mock_ws.run_forever.side_effect = fake_run_forever + with patch.object(client, "_create_ws_app", return_value=mock_ws): + client._ws = mock_ws + client._run_forever_safe() + + assert call_count == target_calls + + def test_delay_capped_by_max_reconnect_backoff(self, mock_session) -> None: + """When max_reconnect_backoff is set, the backoff delay never exceeds it.""" + cap = 0.05 + client = self._make_client( + mock_session, + max_reconnect_attempts=5, + reconnect_backoff=0.01, + max_reconnect_backoff=cap, + ) + observed_delays: list[float] = [] + + original_wait = client._user_disconnect.wait + + def capture_delay(timeout=None): + if timeout is not None: + observed_delays.append(timeout) + return original_wait(timeout=0) # don't actually sleep + + def fake_run_forever(**kwargs): + client._handle_close(client._ws, 1006, "drop") + + mock_ws = Mock() + mock_ws.run_forever.side_effect = fake_run_forever + with ( + patch.object(client, "_create_ws_app", return_value=mock_ws), + patch.object(client._user_disconnect, "wait", side_effect=capture_delay), + ): + client._ws = mock_ws + client._run_forever_safe() + + # Should have recorded a delay for each reconnect attempt + assert len(observed_delays) > 0 + # Every observed delay must be <= cap + for delay in observed_delays: + assert delay <= cap, f"delay {delay} exceeds cap {cap}" + # Without the cap, later delays would grow via exponential backoff + # (e.g., 0.01, 0.02, 0.04, 0.08, 0.16). Verify the cap was actually + # needed by checking that at least one uncapped delay would exceed it. + uncapped = [0.01 * (2 ** i) for i in range(len(observed_delays))] + assert any(d > cap for d in uncapped), "cap was never exercised" + + def test_delay_uncapped_when_max_reconnect_backoff_is_none(self, mock_session) -> None: + """Without max_reconnect_backoff, delays grow without bound.""" + client = self._make_client( + mock_session, + max_reconnect_attempts=4, + reconnect_backoff=0.01, + max_reconnect_backoff=None, + ) + observed_delays: list[float] = [] + + original_wait = client._user_disconnect.wait + + def capture_delay(timeout=None): + if timeout is not None: + observed_delays.append(timeout) + return original_wait(timeout=0) + + def fake_run_forever(**kwargs): + client._handle_close(client._ws, 1006, "drop") + + mock_ws = Mock() + mock_ws.run_forever.side_effect = fake_run_forever + with ( + patch.object(client, "_create_ws_app", return_value=mock_ws), + patch.object(client._user_disconnect, "wait", side_effect=capture_delay), + ): + client._ws = mock_ws + client._run_forever_safe() + + assert len(observed_delays) >= 2 + # Each delay should be double the previous (exponential backoff) + for i in range(1, len(observed_delays)): + assert observed_delays[i] == pytest.approx(observed_delays[i - 1] * 2) + # --------------------------------------------------------------------------- # Callback exception safety From 1ef3d82db668999d15d7d53ed78979bd8601c5fd Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Wed, 1 Apr 2026 11:03:06 -0700 Subject: [PATCH 3/4] Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a09836..99ccf1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,7 @@ ws = mistapi.websockets.sites.DeviceStatsEvents( The stored password is now cleared from memory immediately after successful login authentication. #### **User Attribute Handling** -`_get_self()` now only sets known user attributes (`first_name`, `last_name`, `email`, `enable_two_factor`, `two_factor_verified`, `no_tracking`, `password_expiry`, `password_modified_time`) instead of setting arbitrary response keys as object attributes. +`APISession._getself()` now only sets a fixed set of known user-related attributes instead of setting every response key as an object attribute. --- From c4b784d2c41414b66fe5ac6d8f1c9f34215c21a7 Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Wed, 1 Apr 2026 11:03:42 -0700 Subject: [PATCH 4/4] Update src/mistapi/__api_session.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mistapi/__api_session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mistapi/__api_session.py b/src/mistapi/__api_session.py index a5a83b9..491530c 100644 --- a/src/mistapi/__api_session.py +++ b/src/mistapi/__api_session.py @@ -1153,6 +1153,7 @@ def _getself(self) -> bool: "first_name", "last_name", "email", + "phone", "enable_two_factor", "two_factor_verified", "no_tracking",