From 1582cde79006b9183e9704c613a85dd6dab3a05f Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Wed, 29 Jul 2026 09:26:13 +0300 Subject: [PATCH 01/17] Send X-Authorization header after JWT login --- ce/tb_ce_client/client.py | 10 +++++++ common/client.py | 10 +++++++ paas/tb_paas_client/client.py | 10 +++++++ pe/tb_pe_client/client.py | 10 +++++++ tests/test_client.py | 55 +++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+) diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index 27502cc0..4b2c7cba 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -126,6 +126,16 @@ def __init__( login_api = LoginEndpointApi(api_client) response = login_api.login(LoginRequest(username=username, password=password)) auth_manager.on_login(username, password, response.token, response.refresh_token) + # Seed the header slot, exactly as the api_key and token= branches do. + # Configuration.auth_settings() emits X-Authorization only when + # 'ApiKeyForm' is already in configuration.api_key, and the hook that + # would install it runs inside that same check (via + # get_api_key_with_prefix) — so without this seed the hook can never + # fire and every request goes out unauthenticated (HTTP 401). + # One seed is enough: from here on the hook runs before each request + # and keeps the header in step with refresh / re-login. + configuration.api_key["ApiKeyForm"] = response.token + configuration.api_key_prefix["ApiKeyForm"] = "Bearer" # Pre-existing token if token is not None: diff --git a/common/client.py b/common/client.py index 27502cc0..4b2c7cba 100644 --- a/common/client.py +++ b/common/client.py @@ -126,6 +126,16 @@ def __init__( login_api = LoginEndpointApi(api_client) response = login_api.login(LoginRequest(username=username, password=password)) auth_manager.on_login(username, password, response.token, response.refresh_token) + # Seed the header slot, exactly as the api_key and token= branches do. + # Configuration.auth_settings() emits X-Authorization only when + # 'ApiKeyForm' is already in configuration.api_key, and the hook that + # would install it runs inside that same check (via + # get_api_key_with_prefix) — so without this seed the hook can never + # fire and every request goes out unauthenticated (HTTP 401). + # One seed is enough: from here on the hook runs before each request + # and keeps the header in step with refresh / re-login. + configuration.api_key["ApiKeyForm"] = response.token + configuration.api_key_prefix["ApiKeyForm"] = "Bearer" # Pre-existing token if token is not None: diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index 27502cc0..4b2c7cba 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -126,6 +126,16 @@ def __init__( login_api = LoginEndpointApi(api_client) response = login_api.login(LoginRequest(username=username, password=password)) auth_manager.on_login(username, password, response.token, response.refresh_token) + # Seed the header slot, exactly as the api_key and token= branches do. + # Configuration.auth_settings() emits X-Authorization only when + # 'ApiKeyForm' is already in configuration.api_key, and the hook that + # would install it runs inside that same check (via + # get_api_key_with_prefix) — so without this seed the hook can never + # fire and every request goes out unauthenticated (HTTP 401). + # One seed is enough: from here on the hook runs before each request + # and keeps the header in step with refresh / re-login. + configuration.api_key["ApiKeyForm"] = response.token + configuration.api_key_prefix["ApiKeyForm"] = "Bearer" # Pre-existing token if token is not None: diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index 27502cc0..4b2c7cba 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -126,6 +126,16 @@ def __init__( login_api = LoginEndpointApi(api_client) response = login_api.login(LoginRequest(username=username, password=password)) auth_manager.on_login(username, password, response.token, response.refresh_token) + # Seed the header slot, exactly as the api_key and token= branches do. + # Configuration.auth_settings() emits X-Authorization only when + # 'ApiKeyForm' is already in configuration.api_key, and the hook that + # would install it runs inside that same check (via + # get_api_key_with_prefix) — so without this seed the hook can never + # fire and every request goes out unauthenticated (HTTP 401). + # One seed is enough: from here on the hook runs before each request + # and keeps the header in step with refresh / re-login. + configuration.api_key["ApiKeyForm"] = response.token + configuration.api_key_prefix["ApiKeyForm"] = "Bearer" # Pre-existing token if token is not None: diff --git a/tests/test_client.py b/tests/test_client.py index 87b0a049..d6dcd31c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -41,6 +41,61 @@ def test_jwt_login(self): # Token stored in auth manager self.assertEqual(client.get_token(), mock_resp.token) + def test_jwt_login_seeds_configuration_api_key(self): + """AUTH-01: the login token is installed into configuration, not only the auth manager. + + Configuration.auth_settings() emits the X-Authorization header only when + 'ApiKeyForm' is already present in configuration.api_key, and the + refresh_api_key_hook that would install it runs *inside* that same check + (get_api_key_with_prefix). Storing the token on the auth manager alone + therefore leaves every request unauthenticated. api_key= and token= auth + both seed the slot at construction; JWT login must do the same. + """ + mock_resp = _mock_login_response() + with patch( + "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp + ): + client = ThingsboardClient(URL, "user@tb.io", "pass123") + cfg = client.api_client.configuration + self.assertEqual(cfg.api_key.get("ApiKeyForm"), mock_resp.token) + self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") + + def test_jwt_login_emits_x_authorization_header(self): + """AUTH-01: auth_settings() yields the header an API request actually sends. + + This is the end-to-end assertion through the generated gate — it fails + whenever the token never reaches configuration, which is what produces + HTTP 401 on every call after a successful login. + """ + mock_resp = _mock_login_response() + with patch( + "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp + ): + client = ThingsboardClient(URL, "user@tb.io", "pass123") + auth = client.api_client.configuration.auth_settings() + self.assertIn("ApiKeyForm", auth) + self.assertEqual(auth["ApiKeyForm"]["key"], "X-Authorization") + self.assertEqual(auth["ApiKeyForm"]["value"], f"Bearer {mock_resp.token}") + + def test_jwt_header_follows_token_rotation(self): + """AUTH-02: once seeded, the hook keeps the header in step with new tokens. + + Seeding at login time is sufficient — it does not freeze the first token. + The refresh hook now runs before every request, so a token replaced by + refresh or re-login is picked up on the next call. + """ + mock_resp = _mock_login_response() + with patch( + "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp + ): + client = ThingsboardClient(URL, "user@tb.io", "pass123") + # Simulate what _do_refresh_token / _do_login do on expiry: swap in new tokens. + client._auth_manager.on_login( + "user@tb.io", "pass123", "rotated.jwt.token", "rotated.jwt.refresh" + ) + auth = client.api_client.configuration.auth_settings() + self.assertEqual(auth["ApiKeyForm"]["value"], "Bearer rotated.jwt.token") + def test_api_key_auth(self): """WRAP-01, AUTH-05: api_key sets header without calling login().""" with patch("tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login") as mock_login: From b9f252b891dbfa8ddff0e4bb434458c0ce6d6552 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 10:56:46 +0300 Subject: [PATCH 02/17] Address PR review: reject mixed auth modes, dedupe header install, guard overlay - Reject combining username=/api_key=/token=: under api_key auth the refresh hook is a no-op, so a JWT installed alongside a key was frozen and every request would 401 once it expired. - Move the ApiKeyForm/Bearer/ApiKey literals into _auth.py and install the header via a single _AuthManager.install_header() call for all three modes. - Add tests/test_common_overlay.py asserting each committed tb_*_client copy of common/{client,_auth,_retry}.py is byte-identical to its source, and stop test_facade.py from repairing the ce copy at import time, which masked drift. - Rework the JWT tests: shared _logged_in_client() helper, drop the assertion subsumed by the auth_settings() one, and exercise seed -> hook -> refresh end-to-end instead of hand-swapping token state. --- ce/tb_ce_client/_auth.py | 27 +++++-- ce/tb_ce_client/client.py | 44 +++++++----- common/_auth.py | 27 +++++-- common/client.py | 44 +++++++----- paas/tb_paas_client/_auth.py | 27 +++++-- paas/tb_paas_client/client.py | 44 +++++++----- pe/tb_pe_client/_auth.py | 27 +++++-- pe/tb_pe_client/client.py | 44 +++++++----- tests/test_client.py | 128 ++++++++++++++++++---------------- tests/test_common_overlay.py | 46 ++++++++++++ tests/test_facade.py | 12 +--- 11 files changed, 315 insertions(+), 155 deletions(-) create mode 100644 tests/test_common_overlay.py diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 1c793d11..31fb5b92 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -34,6 +34,13 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 +# Security scheme name and prefixes dictated by the generated configuration.py. +# Keep them in one place so a spec regeneration that renames the scheme has a +# single owner instead of literals scattered across client.py and _auth.py. +_SECURITY_SCHEME = "ApiKeyForm" +_JWT_PREFIX = "Bearer" +_API_KEY_PREFIX = "ApiKey" + # --------------------------------------------------------------------------- # _TokenInfo @@ -148,6 +155,21 @@ def get_refresh_token(self): """Return the current refresh token, or None if not available.""" return self._token_info.refresh_token + def install_header(self, configuration) -> None: + """Write the current token into configuration's X-Authorization slots. + + Configuration.auth_settings() emits the header only when the security + scheme is already present in configuration.api_key, so the slot has to be + seeded at construction time before the hook can ever take over. + """ + token = self._token_info.token + if not token: + return + configuration.api_key[_SECURITY_SCHEME] = token + configuration.api_key_prefix[_SECURITY_SCHEME] = ( + _API_KEY_PREFIX if self._auth_type == "api_key" else _JWT_PREFIX + ) + def hook(self, configuration) -> None: """refresh_api_key_hook implementation. @@ -159,10 +181,7 @@ def hook(self, configuration) -> None: # API key auth — hook is a no-op; the key is set at construction time return self._refresh_if_needed() - token = self._token_info.token - if token: - configuration.api_key["ApiKeyForm"] = token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" + self.install_header(configuration) # ------------------------------------------------------------------ # Internal refresh logic diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index 4b2c7cba..f48a3bd1 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -53,6 +53,8 @@ class ThingsboardClient: ThingsboardClient(url, token="jwt", refresh_token="jwt") Injects an externally obtained JWT; no login call made. + The three modes are mutually exclusive — passing more than one raises ValueError. + Context manager usage: with ThingsboardClient(url, api_key="key") as client: devices = client.get_tenant_devices(page_size=10, page=0) @@ -85,11 +87,29 @@ def __init__( max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). retry_on_rate_limit: If True (default), wraps rest_client with _RetryingRESTClient. If False, uses plain RESTClientObject. + + Raises: + ValueError: If more than one of username=, api_key= or token= is given. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} + # The three auth modes share a single X-Authorization slot, so combining + # them is ambiguous: whichever ran last would win, and under api_key auth + # the refresh hook is a no-op, so a JWT installed alongside a key would be + # frozen at its initial value and never refreshed. + modes = [ + name + for name, value in (("username", username), ("api_key", api_key), ("token", token)) + if value is not None + ] + if len(modes) > 1: + raise ValueError( + "ThingsboardClient authentication modes are mutually exclusive; " + f"got {', '.join(modes)}" + ) + configuration = Configuration(host=url) # Determine auth type @@ -99,11 +119,6 @@ def __init__( # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook - # API key auth: set header at construction time - if api_key is not None: - configuration.api_key["ApiKeyForm"] = api_key - configuration.api_key_prefix["ApiKeyForm"] = "ApiKey" - # Build the ApiClient api_client = ApiClient(configuration=configuration) @@ -126,22 +141,17 @@ def __init__( login_api = LoginEndpointApi(api_client) response = login_api.login(LoginRequest(username=username, password=password)) auth_manager.on_login(username, password, response.token, response.refresh_token) - # Seed the header slot, exactly as the api_key and token= branches do. - # Configuration.auth_settings() emits X-Authorization only when - # 'ApiKeyForm' is already in configuration.api_key, and the hook that - # would install it runs inside that same check (via - # get_api_key_with_prefix) — so without this seed the hook can never - # fire and every request goes out unauthenticated (HTTP 401). - # One seed is enough: from here on the hook runs before each request - # and keeps the header in step with refresh / re-login. - configuration.api_key["ApiKeyForm"] = response.token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" # Pre-existing token if token is not None: auth_manager.set_external_token(token, refresh_token) - configuration.api_key["ApiKeyForm"] = token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" + + # Seed the X-Authorization slot for whichever mode was used: + # Configuration.auth_settings() only emits the header when the security + # scheme is already present in configuration.api_key, so the hook that + # would install it can never fire until the slot exists. One seed is + # enough — from here the hook keeps the header in step with every refresh. + auth_manager.install_header(configuration) # ------------------------------------------------------------------ # Controller delegation diff --git a/common/_auth.py b/common/_auth.py index 1c793d11..31fb5b92 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -34,6 +34,13 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 +# Security scheme name and prefixes dictated by the generated configuration.py. +# Keep them in one place so a spec regeneration that renames the scheme has a +# single owner instead of literals scattered across client.py and _auth.py. +_SECURITY_SCHEME = "ApiKeyForm" +_JWT_PREFIX = "Bearer" +_API_KEY_PREFIX = "ApiKey" + # --------------------------------------------------------------------------- # _TokenInfo @@ -148,6 +155,21 @@ def get_refresh_token(self): """Return the current refresh token, or None if not available.""" return self._token_info.refresh_token + def install_header(self, configuration) -> None: + """Write the current token into configuration's X-Authorization slots. + + Configuration.auth_settings() emits the header only when the security + scheme is already present in configuration.api_key, so the slot has to be + seeded at construction time before the hook can ever take over. + """ + token = self._token_info.token + if not token: + return + configuration.api_key[_SECURITY_SCHEME] = token + configuration.api_key_prefix[_SECURITY_SCHEME] = ( + _API_KEY_PREFIX if self._auth_type == "api_key" else _JWT_PREFIX + ) + def hook(self, configuration) -> None: """refresh_api_key_hook implementation. @@ -159,10 +181,7 @@ def hook(self, configuration) -> None: # API key auth — hook is a no-op; the key is set at construction time return self._refresh_if_needed() - token = self._token_info.token - if token: - configuration.api_key["ApiKeyForm"] = token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" + self.install_header(configuration) # ------------------------------------------------------------------ # Internal refresh logic diff --git a/common/client.py b/common/client.py index 4b2c7cba..f48a3bd1 100644 --- a/common/client.py +++ b/common/client.py @@ -53,6 +53,8 @@ class ThingsboardClient: ThingsboardClient(url, token="jwt", refresh_token="jwt") Injects an externally obtained JWT; no login call made. + The three modes are mutually exclusive — passing more than one raises ValueError. + Context manager usage: with ThingsboardClient(url, api_key="key") as client: devices = client.get_tenant_devices(page_size=10, page=0) @@ -85,11 +87,29 @@ def __init__( max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). retry_on_rate_limit: If True (default), wraps rest_client with _RetryingRESTClient. If False, uses plain RESTClientObject. + + Raises: + ValueError: If more than one of username=, api_key= or token= is given. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} + # The three auth modes share a single X-Authorization slot, so combining + # them is ambiguous: whichever ran last would win, and under api_key auth + # the refresh hook is a no-op, so a JWT installed alongside a key would be + # frozen at its initial value and never refreshed. + modes = [ + name + for name, value in (("username", username), ("api_key", api_key), ("token", token)) + if value is not None + ] + if len(modes) > 1: + raise ValueError( + "ThingsboardClient authentication modes are mutually exclusive; " + f"got {', '.join(modes)}" + ) + configuration = Configuration(host=url) # Determine auth type @@ -99,11 +119,6 @@ def __init__( # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook - # API key auth: set header at construction time - if api_key is not None: - configuration.api_key["ApiKeyForm"] = api_key - configuration.api_key_prefix["ApiKeyForm"] = "ApiKey" - # Build the ApiClient api_client = ApiClient(configuration=configuration) @@ -126,22 +141,17 @@ def __init__( login_api = LoginEndpointApi(api_client) response = login_api.login(LoginRequest(username=username, password=password)) auth_manager.on_login(username, password, response.token, response.refresh_token) - # Seed the header slot, exactly as the api_key and token= branches do. - # Configuration.auth_settings() emits X-Authorization only when - # 'ApiKeyForm' is already in configuration.api_key, and the hook that - # would install it runs inside that same check (via - # get_api_key_with_prefix) — so without this seed the hook can never - # fire and every request goes out unauthenticated (HTTP 401). - # One seed is enough: from here on the hook runs before each request - # and keeps the header in step with refresh / re-login. - configuration.api_key["ApiKeyForm"] = response.token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" # Pre-existing token if token is not None: auth_manager.set_external_token(token, refresh_token) - configuration.api_key["ApiKeyForm"] = token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" + + # Seed the X-Authorization slot for whichever mode was used: + # Configuration.auth_settings() only emits the header when the security + # scheme is already present in configuration.api_key, so the hook that + # would install it can never fire until the slot exists. One seed is + # enough — from here the hook keeps the header in step with every refresh. + auth_manager.install_header(configuration) # ------------------------------------------------------------------ # Controller delegation diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 1c793d11..31fb5b92 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -34,6 +34,13 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 +# Security scheme name and prefixes dictated by the generated configuration.py. +# Keep them in one place so a spec regeneration that renames the scheme has a +# single owner instead of literals scattered across client.py and _auth.py. +_SECURITY_SCHEME = "ApiKeyForm" +_JWT_PREFIX = "Bearer" +_API_KEY_PREFIX = "ApiKey" + # --------------------------------------------------------------------------- # _TokenInfo @@ -148,6 +155,21 @@ def get_refresh_token(self): """Return the current refresh token, or None if not available.""" return self._token_info.refresh_token + def install_header(self, configuration) -> None: + """Write the current token into configuration's X-Authorization slots. + + Configuration.auth_settings() emits the header only when the security + scheme is already present in configuration.api_key, so the slot has to be + seeded at construction time before the hook can ever take over. + """ + token = self._token_info.token + if not token: + return + configuration.api_key[_SECURITY_SCHEME] = token + configuration.api_key_prefix[_SECURITY_SCHEME] = ( + _API_KEY_PREFIX if self._auth_type == "api_key" else _JWT_PREFIX + ) + def hook(self, configuration) -> None: """refresh_api_key_hook implementation. @@ -159,10 +181,7 @@ def hook(self, configuration) -> None: # API key auth — hook is a no-op; the key is set at construction time return self._refresh_if_needed() - token = self._token_info.token - if token: - configuration.api_key["ApiKeyForm"] = token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" + self.install_header(configuration) # ------------------------------------------------------------------ # Internal refresh logic diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index 4b2c7cba..f48a3bd1 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -53,6 +53,8 @@ class ThingsboardClient: ThingsboardClient(url, token="jwt", refresh_token="jwt") Injects an externally obtained JWT; no login call made. + The three modes are mutually exclusive — passing more than one raises ValueError. + Context manager usage: with ThingsboardClient(url, api_key="key") as client: devices = client.get_tenant_devices(page_size=10, page=0) @@ -85,11 +87,29 @@ def __init__( max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). retry_on_rate_limit: If True (default), wraps rest_client with _RetryingRESTClient. If False, uses plain RESTClientObject. + + Raises: + ValueError: If more than one of username=, api_key= or token= is given. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} + # The three auth modes share a single X-Authorization slot, so combining + # them is ambiguous: whichever ran last would win, and under api_key auth + # the refresh hook is a no-op, so a JWT installed alongside a key would be + # frozen at its initial value and never refreshed. + modes = [ + name + for name, value in (("username", username), ("api_key", api_key), ("token", token)) + if value is not None + ] + if len(modes) > 1: + raise ValueError( + "ThingsboardClient authentication modes are mutually exclusive; " + f"got {', '.join(modes)}" + ) + configuration = Configuration(host=url) # Determine auth type @@ -99,11 +119,6 @@ def __init__( # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook - # API key auth: set header at construction time - if api_key is not None: - configuration.api_key["ApiKeyForm"] = api_key - configuration.api_key_prefix["ApiKeyForm"] = "ApiKey" - # Build the ApiClient api_client = ApiClient(configuration=configuration) @@ -126,22 +141,17 @@ def __init__( login_api = LoginEndpointApi(api_client) response = login_api.login(LoginRequest(username=username, password=password)) auth_manager.on_login(username, password, response.token, response.refresh_token) - # Seed the header slot, exactly as the api_key and token= branches do. - # Configuration.auth_settings() emits X-Authorization only when - # 'ApiKeyForm' is already in configuration.api_key, and the hook that - # would install it runs inside that same check (via - # get_api_key_with_prefix) — so without this seed the hook can never - # fire and every request goes out unauthenticated (HTTP 401). - # One seed is enough: from here on the hook runs before each request - # and keeps the header in step with refresh / re-login. - configuration.api_key["ApiKeyForm"] = response.token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" # Pre-existing token if token is not None: auth_manager.set_external_token(token, refresh_token) - configuration.api_key["ApiKeyForm"] = token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" + + # Seed the X-Authorization slot for whichever mode was used: + # Configuration.auth_settings() only emits the header when the security + # scheme is already present in configuration.api_key, so the hook that + # would install it can never fire until the slot exists. One seed is + # enough — from here the hook keeps the header in step with every refresh. + auth_manager.install_header(configuration) # ------------------------------------------------------------------ # Controller delegation diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 1c793d11..31fb5b92 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -34,6 +34,13 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 +# Security scheme name and prefixes dictated by the generated configuration.py. +# Keep them in one place so a spec regeneration that renames the scheme has a +# single owner instead of literals scattered across client.py and _auth.py. +_SECURITY_SCHEME = "ApiKeyForm" +_JWT_PREFIX = "Bearer" +_API_KEY_PREFIX = "ApiKey" + # --------------------------------------------------------------------------- # _TokenInfo @@ -148,6 +155,21 @@ def get_refresh_token(self): """Return the current refresh token, or None if not available.""" return self._token_info.refresh_token + def install_header(self, configuration) -> None: + """Write the current token into configuration's X-Authorization slots. + + Configuration.auth_settings() emits the header only when the security + scheme is already present in configuration.api_key, so the slot has to be + seeded at construction time before the hook can ever take over. + """ + token = self._token_info.token + if not token: + return + configuration.api_key[_SECURITY_SCHEME] = token + configuration.api_key_prefix[_SECURITY_SCHEME] = ( + _API_KEY_PREFIX if self._auth_type == "api_key" else _JWT_PREFIX + ) + def hook(self, configuration) -> None: """refresh_api_key_hook implementation. @@ -159,10 +181,7 @@ def hook(self, configuration) -> None: # API key auth — hook is a no-op; the key is set at construction time return self._refresh_if_needed() - token = self._token_info.token - if token: - configuration.api_key["ApiKeyForm"] = token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" + self.install_header(configuration) # ------------------------------------------------------------------ # Internal refresh logic diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index 4b2c7cba..f48a3bd1 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -53,6 +53,8 @@ class ThingsboardClient: ThingsboardClient(url, token="jwt", refresh_token="jwt") Injects an externally obtained JWT; no login call made. + The three modes are mutually exclusive — passing more than one raises ValueError. + Context manager usage: with ThingsboardClient(url, api_key="key") as client: devices = client.get_tenant_devices(page_size=10, page=0) @@ -85,11 +87,29 @@ def __init__( max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). retry_on_rate_limit: If True (default), wraps rest_client with _RetryingRESTClient. If False, uses plain RESTClientObject. + + Raises: + ValueError: If more than one of username=, api_key= or token= is given. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} + # The three auth modes share a single X-Authorization slot, so combining + # them is ambiguous: whichever ran last would win, and under api_key auth + # the refresh hook is a no-op, so a JWT installed alongside a key would be + # frozen at its initial value and never refreshed. + modes = [ + name + for name, value in (("username", username), ("api_key", api_key), ("token", token)) + if value is not None + ] + if len(modes) > 1: + raise ValueError( + "ThingsboardClient authentication modes are mutually exclusive; " + f"got {', '.join(modes)}" + ) + configuration = Configuration(host=url) # Determine auth type @@ -99,11 +119,6 @@ def __init__( # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook - # API key auth: set header at construction time - if api_key is not None: - configuration.api_key["ApiKeyForm"] = api_key - configuration.api_key_prefix["ApiKeyForm"] = "ApiKey" - # Build the ApiClient api_client = ApiClient(configuration=configuration) @@ -126,22 +141,17 @@ def __init__( login_api = LoginEndpointApi(api_client) response = login_api.login(LoginRequest(username=username, password=password)) auth_manager.on_login(username, password, response.token, response.refresh_token) - # Seed the header slot, exactly as the api_key and token= branches do. - # Configuration.auth_settings() emits X-Authorization only when - # 'ApiKeyForm' is already in configuration.api_key, and the hook that - # would install it runs inside that same check (via - # get_api_key_with_prefix) — so without this seed the hook can never - # fire and every request goes out unauthenticated (HTTP 401). - # One seed is enough: from here on the hook runs before each request - # and keeps the header in step with refresh / re-login. - configuration.api_key["ApiKeyForm"] = response.token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" # Pre-existing token if token is not None: auth_manager.set_external_token(token, refresh_token) - configuration.api_key["ApiKeyForm"] = token - configuration.api_key_prefix["ApiKeyForm"] = "Bearer" + + # Seed the X-Authorization slot for whichever mode was used: + # Configuration.auth_settings() only emits the header when the security + # scheme is already present in configuration.api_key, so the hook that + # would install it can never fire until the slot exists. One seed is + # enough — from here the hook keeps the header in step with every refresh. + auth_manager.install_header(configuration) # ------------------------------------------------------------------ # Controller delegation diff --git a/tests/test_client.py b/tests/test_client.py index d6dcd31c..4217d173 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,12 +1,12 @@ """ Unit tests for ThingsboardClient — WRAP-01 through WRAP-04. -All tests import from tb_ce_client.client (after common/ is copied to ce/). -conftest.py adds ce/ to sys.path so this works without packaging. +All tests import from tb_ce_client.client — the committed overlay copy of +common/client.py. conftest.py adds ce/ to sys.path so this works without packaging. """ import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch from tb_ce_client._retry import _RetryingRESTClient @@ -14,8 +14,14 @@ from tb_ce_client.client import ThingsboardClient from tb_ce_client.rest import RESTClientObject +from tests.test_auth import _make_refresh_token, _make_token + URL = "http://tb-server:9090" +# Patch the login endpoint at its module path so the mock survives module eviction +# and re-import by test_split.py's lazy-load tests. +_LOGIN_PATCH_TARGET = "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login" + def _mock_login_response(token="test.jwt.token", refresh_token="test.jwt.refresh"): """Build a mock LoginResponse object.""" @@ -25,80 +31,59 @@ def _mock_login_response(token="test.jwt.token", refresh_token="test.jwt.refresh return resp +def _logged_in_client(token="test.jwt.token", refresh_token="test.jwt.refresh"): + """Construct a ThingsboardClient through a mocked username/password login.""" + resp = _mock_login_response(token, refresh_token) + with patch(_LOGIN_PATCH_TARGET, return_value=resp): + return ThingsboardClient(URL, "user@tb.io", "pass123") + + class TestThingsboardClientJWTLogin(unittest.TestCase): """WRAP-01, AUTH-01 integration: username/password login flow.""" def test_jwt_login(self): """ThingsboardClient(url, username, password) calls login() and stores tokens.""" mock_resp = _mock_login_response() - # Use module-path patch so the mock works even if the module was - # evicted and re-imported by test_split.py lazy-load tests. - with patch( - "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp - ) as mock_login: + with patch(_LOGIN_PATCH_TARGET, return_value=mock_resp) as mock_login: client = ThingsboardClient(URL, "user@tb.io", "pass123") mock_login.assert_called_once() # Token stored in auth manager self.assertEqual(client.get_token(), mock_resp.token) - def test_jwt_login_seeds_configuration_api_key(self): - """AUTH-01: the login token is installed into configuration, not only the auth manager. - - Configuration.auth_settings() emits the X-Authorization header only when - 'ApiKeyForm' is already present in configuration.api_key, and the - refresh_api_key_hook that would install it runs *inside* that same check - (get_api_key_with_prefix). Storing the token on the auth manager alone - therefore leaves every request unauthenticated. api_key= and token= auth - both seed the slot at construction; JWT login must do the same. - """ - mock_resp = _mock_login_response() - with patch( - "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp - ): - client = ThingsboardClient(URL, "user@tb.io", "pass123") - cfg = client.api_client.configuration - self.assertEqual(cfg.api_key.get("ApiKeyForm"), mock_resp.token) - self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") - def test_jwt_login_emits_x_authorization_header(self): """AUTH-01: auth_settings() yields the header an API request actually sends. - This is the end-to-end assertion through the generated gate — it fails - whenever the token never reaches configuration, which is what produces - HTTP 401 on every call after a successful login. + auth_settings() only emits the header when 'ApiKeyForm' is already present + in configuration.api_key, so the slot has to be seeded before the refresh + hook — which runs inside that same check — can ever take over. """ - mock_resp = _mock_login_response() - with patch( - "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp - ): - client = ThingsboardClient(URL, "user@tb.io", "pass123") + client = _logged_in_client() auth = client.api_client.configuration.auth_settings() self.assertIn("ApiKeyForm", auth) self.assertEqual(auth["ApiKeyForm"]["key"], "X-Authorization") - self.assertEqual(auth["ApiKeyForm"]["value"], f"Bearer {mock_resp.token}") + self.assertEqual(auth["ApiKeyForm"]["value"], "Bearer test.jwt.token") def test_jwt_header_follows_token_rotation(self): - """AUTH-02: once seeded, the hook keeps the header in step with new tokens. + """AUTH-02: seeding at login does not freeze the first token. - Seeding at login time is sufficient — it does not freeze the first token. - The refresh hook now runs before every request, so a token replaced by - refresh or re-login is picked up on the next call. + Covers the whole seed -> hook -> refresh chain: the client logs in with an + already-expired access token, and reading auth_settings() drives the hook + through a real /api/auth/token refresh before the header is assembled. """ - mock_resp = _mock_login_response() - with patch( - "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp - ): - client = ThingsboardClient(URL, "user@tb.io", "pass123") - # Simulate what _do_refresh_token / _do_login do on expiry: swap in new tokens. - client._auth_manager.on_login( - "user@tb.io", "pass123", "rotated.jwt.token", "rotated.jwt.refresh" + client = _logged_in_client( + token=_make_token(exp_offset_s=-3600), + refresh_token=_make_refresh_token(exp_offset_s=86400), ) - auth = client.api_client.configuration.auth_settings() - self.assertEqual(auth["ApiKeyForm"]["value"], "Bearer rotated.jwt.token") + rotated = _make_token(exp_offset_s=3600) + refreshed = {"token": rotated, "refreshToken": _make_refresh_token(exp_offset_s=172800)} + with patch.object(client._auth_manager, "_raw_post", return_value=refreshed) as mock_post: + auth = client.api_client.configuration.auth_settings() + mock_post.assert_called_once_with("/api/auth/token", ANY) + self.assertEqual(auth["ApiKeyForm"]["value"], f"Bearer {rotated}") def test_api_key_auth(self): """WRAP-01, AUTH-05: api_key sets header without calling login().""" - with patch("tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login") as mock_login: + with patch(_LOGIN_PATCH_TARGET) as mock_login: client = ThingsboardClient(URL, api_key="test-key") mock_login.assert_not_called() cfg = client.api_client.configuration @@ -107,7 +92,7 @@ def test_api_key_auth(self): def test_preexisting_token(self): """WRAP-01, AUTH-06: pre-existing token sets header without login().""" - with patch("tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login") as mock_login: + with patch(_LOGIN_PATCH_TARGET) as mock_login: client = ThingsboardClient( URL, token="jwt.payload.sig", refresh_token="jwt.payload.sig" ) @@ -116,6 +101,33 @@ def test_preexisting_token(self): self.assertEqual(cfg.api_key.get("ApiKeyForm"), "jwt.payload.sig") +class TestThingsboardClientAuthArgValidation(unittest.TestCase): + """The three auth modes share one X-Authorization slot, so mixing them is rejected.""" + + def test_api_key_with_username_rejected(self): + """api_key= plus username= raises before any login call is made. + + Allowing both would install a JWT under api_key auth, where the refresh + hook is a no-op — the token would be frozen and every request would start + failing with 401 once it expired. + """ + with patch(_LOGIN_PATCH_TARGET) as mock_login: + with self.assertRaises(ValueError): + ThingsboardClient(URL, "user@tb.io", "pass123", api_key="test-key") + mock_login.assert_not_called() + + def test_api_key_with_token_rejected(self): + """api_key= plus token= raises.""" + with self.assertRaises(ValueError): + ThingsboardClient(URL, api_key="test-key", token="jwt.payload.sig") + + def test_username_with_token_rejected(self): + """username= plus token= raises.""" + with patch(_LOGIN_PATCH_TARGET): + with self.assertRaises(ValueError): + ThingsboardClient(URL, "user@tb.io", "pass123", token="jwt.payload.sig") + + class TestThingsboardClientStructure(unittest.TestCase): """WRAP-02: ThingsboardClient has api_client and _auth_manager attributes.""" @@ -198,20 +210,12 @@ def test_get_token_api_key(self): def test_get_token_jwt(self): """get_token() returns the JWT after successful login.""" - mock_resp = _mock_login_response(token="access.jwt.here") - with patch( - "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp - ): - client = ThingsboardClient(URL, "u", "p") + client = _logged_in_client(token="access.jwt.here") self.assertEqual(client.get_token(), "access.jwt.here") def test_get_refresh_token_jwt(self): """get_refresh_token() returns the refresh JWT after login.""" - mock_resp = _mock_login_response(refresh_token="refresh.jwt.here") - with patch( - "tb_ce_client.api.login_endpoint_api.LoginEndpointApi.login", return_value=mock_resp - ): - client = ThingsboardClient(URL, "u", "p") + client = _logged_in_client(refresh_token="refresh.jwt.here") self.assertEqual(client.get_refresh_token(), "refresh.jwt.here") diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py new file mode 100644 index 00000000..5b56fac0 --- /dev/null +++ b/tests/test_common_overlay.py @@ -0,0 +1,46 @@ +""" +Guards the common/ -> edition overlay performed by generate-client.sh. + +generate-client.sh copies common/*.py verbatim into every tb__client/ +package, and those copies are committed. Nothing else in CI compares them, so a +fix landed in common/ but overlaid into only some editions would ship stale code +to the rest. These tests fail on exactly that. +""" + +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).parent.parent + +# Hand-written modules overlaid verbatim. __init__.py is excluded: post_process.py +# merges it with the generated package __init__, so the copies legitimately differ. +_OVERLAID_MODULES = ["client.py", "_auth.py", "_retry.py"] + +_EDITIONS = ["ce", "pe", "paas"] + + +@pytest.mark.parametrize("edition", _EDITIONS) +@pytest.mark.parametrize("module", _OVERLAID_MODULES) +def test_edition_copy_matches_common(edition, module): + """Each committed edition copy is byte-identical to its common/ source.""" + source = _REPO_ROOT / "common" / module + copy = _REPO_ROOT / edition / f"tb_{edition}_client" / module + + assert copy.exists(), f"{copy} is missing — run generate-client.sh" + assert copy.read_bytes() == source.read_bytes(), ( + f"{copy.relative_to(_REPO_ROOT)} is out of sync with common/{module}. " + f"Edit common/{module} and re-run generate-client.sh (or copy it into " + f"every tb_*_client/ package)." + ) + + +def test_all_common_modules_are_covered(): + """_OVERLAID_MODULES lists every hand-written module in common/. + + Without this, adding a new file to common/ would silently escape the sync check. + """ + present = { + path.name for path in (_REPO_ROOT / "common").glob("*.py") if path.name != "__init__.py" + } + assert present == set(_OVERLAID_MODULES) diff --git a/tests/test_facade.py b/tests/test_facade.py index 8a739fd4..bc53abb0 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -38,16 +38,10 @@ if _CE_DIR not in sys.path: sys.path.insert(0, _CE_DIR) -# Also copy common/client.py to ce/tb_ce_client/client.py if it's missing __getattr__ -# (after implementation the copy happens at test time to avoid stale state) -import shutil +# Do not overlay common/client.py onto the ce copy here: the committed copy is what +# users install, and repairing it at import time would defeat tests/test_common_overlay.py. -_COMMON_CLIENT = _REPO_ROOT / "common" / "client.py" -_CE_CLIENT = _CE_PKG_DIR / "client.py" -if _COMMON_CLIENT.exists(): - shutil.copy2(str(_COMMON_CLIENT), str(_CE_CLIENT)) - -# Evict any stale tb_ce_client imports so the updated client.py is picked up +# Evict any stale tb_ce_client imports so the committed client.py is picked up for mod_name in list(sys.modules.keys()): if mod_name == "tb_ce_client" or mod_name.startswith("tb_ce_client."): del sys.modules[mod_name] From aea18bb9553192ad4351e84996f225c3d0f93966 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 11:30:59 +0300 Subject: [PATCH 03/17] Address re-review: tighten auth arg validation, share JWT factories, derive overlay lists - Reject password= without username= and refresh_token= without token=; both were read only inside their own mode's branch and otherwise dropped silently. - Resolve the header prefix once in _AuthManager.__init__ instead of branching on the stringly-typed _auth_type inside install_header() on every request. - Collapse the seed comment in client.py to a pointer at install_header(), which already owns the explanation. - Move the JWT factories to tests/_jwt.py so test_client.py no longer reaches into test_auth.py for underscore-prefixed helpers. - Derive both the module list and the edition list in test_common_overlay.py from the repo, so a new edition directory can no longer go unchecked; the meta-test it replaces is gone, with a non-empty guard against a vacuous glob. - Cover the untested branches: auth-less client creates no header slot, the token= path asserts its prefix, and the validation tests now pin the error message and the all-three-modes case. --- ce/tb_ce_client/_auth.py | 9 +++-- ce/tb_ce_client/client.py | 15 ++++---- common/_auth.py | 9 +++-- common/client.py | 15 ++++---- paas/tb_paas_client/_auth.py | 9 +++-- paas/tb_paas_client/client.py | 15 ++++---- pe/tb_pe_client/_auth.py | 9 +++-- pe/tb_pe_client/client.py | 15 ++++---- tests/_jwt.py | 48 ++++++++++++++++++++++++++ tests/test_auth.py | 41 +--------------------- tests/test_client.py | 49 ++++++++++++++++++++------- tests/test_common_overlay.py | 64 +++++++++++++++++++++-------------- 12 files changed, 184 insertions(+), 114 deletions(-) create mode 100644 tests/_jwt.py diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 31fb5b92..81fda81e 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -123,6 +123,9 @@ def __init__(self, base_url: str, auth_type: str, api_key=None): """ self._base_url = base_url.rstrip("/") self._auth_type = auth_type + # The header prefix follows from auth_type and never changes afterwards, + # so resolve it once here instead of on every install_header() call. + self._header_prefix = _API_KEY_PREFIX if auth_type == "api_key" else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False self._username = None @@ -164,11 +167,11 @@ def install_header(self, configuration) -> None: """ token = self._token_info.token if not token: + # No auth configured (e.g. /api/noauth usage) — leave the slot absent + # so auth_settings() emits no header at all. return configuration.api_key[_SECURITY_SCHEME] = token - configuration.api_key_prefix[_SECURITY_SCHEME] = ( - _API_KEY_PREFIX if self._auth_type == "api_key" else _JWT_PREFIX - ) + configuration.api_key_prefix[_SECURITY_SCHEME] = self._header_prefix def hook(self, configuration) -> None: """refresh_api_key_hook implementation. diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index f48a3bd1..f6f8fcbc 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -89,7 +89,8 @@ def __init__( _RetryingRESTClient. If False, uses plain RESTClientObject. Raises: - ValueError: If more than one of username=, api_key= or token= is given. + ValueError: If more than one of username=, api_key= or token= is given, + or if password=/refresh_token= is given without its own mode. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -109,6 +110,12 @@ def __init__( "ThingsboardClient authentication modes are mutually exclusive; " f"got {', '.join(modes)}" ) + # password= and refresh_token= are only read by their own mode's branch, so + # on their own they would be silently dropped and surface later as a 401. + if password is not None and username is None: + raise ValueError("password= requires username=") + if refresh_token is not None and token is None: + raise ValueError("refresh_token= requires token=") configuration = Configuration(host=url) @@ -146,11 +153,7 @@ def __init__( if token is not None: auth_manager.set_external_token(token, refresh_token) - # Seed the X-Authorization slot for whichever mode was used: - # Configuration.auth_settings() only emits the header when the security - # scheme is already present in configuration.api_key, so the hook that - # would install it can never fire until the slot exists. One seed is - # enough — from here the hook keeps the header in step with every refresh. + # Seed the header slot for whichever mode ran — see _AuthManager.install_header. auth_manager.install_header(configuration) # ------------------------------------------------------------------ diff --git a/common/_auth.py b/common/_auth.py index 31fb5b92..81fda81e 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -123,6 +123,9 @@ def __init__(self, base_url: str, auth_type: str, api_key=None): """ self._base_url = base_url.rstrip("/") self._auth_type = auth_type + # The header prefix follows from auth_type and never changes afterwards, + # so resolve it once here instead of on every install_header() call. + self._header_prefix = _API_KEY_PREFIX if auth_type == "api_key" else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False self._username = None @@ -164,11 +167,11 @@ def install_header(self, configuration) -> None: """ token = self._token_info.token if not token: + # No auth configured (e.g. /api/noauth usage) — leave the slot absent + # so auth_settings() emits no header at all. return configuration.api_key[_SECURITY_SCHEME] = token - configuration.api_key_prefix[_SECURITY_SCHEME] = ( - _API_KEY_PREFIX if self._auth_type == "api_key" else _JWT_PREFIX - ) + configuration.api_key_prefix[_SECURITY_SCHEME] = self._header_prefix def hook(self, configuration) -> None: """refresh_api_key_hook implementation. diff --git a/common/client.py b/common/client.py index f48a3bd1..f6f8fcbc 100644 --- a/common/client.py +++ b/common/client.py @@ -89,7 +89,8 @@ def __init__( _RetryingRESTClient. If False, uses plain RESTClientObject. Raises: - ValueError: If more than one of username=, api_key= or token= is given. + ValueError: If more than one of username=, api_key= or token= is given, + or if password=/refresh_token= is given without its own mode. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -109,6 +110,12 @@ def __init__( "ThingsboardClient authentication modes are mutually exclusive; " f"got {', '.join(modes)}" ) + # password= and refresh_token= are only read by their own mode's branch, so + # on their own they would be silently dropped and surface later as a 401. + if password is not None and username is None: + raise ValueError("password= requires username=") + if refresh_token is not None and token is None: + raise ValueError("refresh_token= requires token=") configuration = Configuration(host=url) @@ -146,11 +153,7 @@ def __init__( if token is not None: auth_manager.set_external_token(token, refresh_token) - # Seed the X-Authorization slot for whichever mode was used: - # Configuration.auth_settings() only emits the header when the security - # scheme is already present in configuration.api_key, so the hook that - # would install it can never fire until the slot exists. One seed is - # enough — from here the hook keeps the header in step with every refresh. + # Seed the header slot for whichever mode ran — see _AuthManager.install_header. auth_manager.install_header(configuration) # ------------------------------------------------------------------ diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 31fb5b92..81fda81e 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -123,6 +123,9 @@ def __init__(self, base_url: str, auth_type: str, api_key=None): """ self._base_url = base_url.rstrip("/") self._auth_type = auth_type + # The header prefix follows from auth_type and never changes afterwards, + # so resolve it once here instead of on every install_header() call. + self._header_prefix = _API_KEY_PREFIX if auth_type == "api_key" else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False self._username = None @@ -164,11 +167,11 @@ def install_header(self, configuration) -> None: """ token = self._token_info.token if not token: + # No auth configured (e.g. /api/noauth usage) — leave the slot absent + # so auth_settings() emits no header at all. return configuration.api_key[_SECURITY_SCHEME] = token - configuration.api_key_prefix[_SECURITY_SCHEME] = ( - _API_KEY_PREFIX if self._auth_type == "api_key" else _JWT_PREFIX - ) + configuration.api_key_prefix[_SECURITY_SCHEME] = self._header_prefix def hook(self, configuration) -> None: """refresh_api_key_hook implementation. diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index f48a3bd1..f6f8fcbc 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -89,7 +89,8 @@ def __init__( _RetryingRESTClient. If False, uses plain RESTClientObject. Raises: - ValueError: If more than one of username=, api_key= or token= is given. + ValueError: If more than one of username=, api_key= or token= is given, + or if password=/refresh_token= is given without its own mode. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -109,6 +110,12 @@ def __init__( "ThingsboardClient authentication modes are mutually exclusive; " f"got {', '.join(modes)}" ) + # password= and refresh_token= are only read by their own mode's branch, so + # on their own they would be silently dropped and surface later as a 401. + if password is not None and username is None: + raise ValueError("password= requires username=") + if refresh_token is not None and token is None: + raise ValueError("refresh_token= requires token=") configuration = Configuration(host=url) @@ -146,11 +153,7 @@ def __init__( if token is not None: auth_manager.set_external_token(token, refresh_token) - # Seed the X-Authorization slot for whichever mode was used: - # Configuration.auth_settings() only emits the header when the security - # scheme is already present in configuration.api_key, so the hook that - # would install it can never fire until the slot exists. One seed is - # enough — from here the hook keeps the header in step with every refresh. + # Seed the header slot for whichever mode ran — see _AuthManager.install_header. auth_manager.install_header(configuration) # ------------------------------------------------------------------ diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 31fb5b92..81fda81e 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -123,6 +123,9 @@ def __init__(self, base_url: str, auth_type: str, api_key=None): """ self._base_url = base_url.rstrip("/") self._auth_type = auth_type + # The header prefix follows from auth_type and never changes afterwards, + # so resolve it once here instead of on every install_header() call. + self._header_prefix = _API_KEY_PREFIX if auth_type == "api_key" else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False self._username = None @@ -164,11 +167,11 @@ def install_header(self, configuration) -> None: """ token = self._token_info.token if not token: + # No auth configured (e.g. /api/noauth usage) — leave the slot absent + # so auth_settings() emits no header at all. return configuration.api_key[_SECURITY_SCHEME] = token - configuration.api_key_prefix[_SECURITY_SCHEME] = ( - _API_KEY_PREFIX if self._auth_type == "api_key" else _JWT_PREFIX - ) + configuration.api_key_prefix[_SECURITY_SCHEME] = self._header_prefix def hook(self, configuration) -> None: """refresh_api_key_hook implementation. diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index f48a3bd1..f6f8fcbc 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -89,7 +89,8 @@ def __init__( _RetryingRESTClient. If False, uses plain RESTClientObject. Raises: - ValueError: If more than one of username=, api_key= or token= is given. + ValueError: If more than one of username=, api_key= or token= is given, + or if password=/refresh_token= is given without its own mode. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -109,6 +110,12 @@ def __init__( "ThingsboardClient authentication modes are mutually exclusive; " f"got {', '.join(modes)}" ) + # password= and refresh_token= are only read by their own mode's branch, so + # on their own they would be silently dropped and surface later as a 401. + if password is not None and username is None: + raise ValueError("password= requires username=") + if refresh_token is not None and token is None: + raise ValueError("refresh_token= requires token=") configuration = Configuration(host=url) @@ -146,11 +153,7 @@ def __init__( if token is not None: auth_manager.set_external_token(token, refresh_token) - # Seed the X-Authorization slot for whichever mode was used: - # Configuration.auth_settings() only emits the header when the security - # scheme is already present in configuration.api_key, so the hook that - # would install it can never fire until the slot exists. One seed is - # enough — from here the hook keeps the header in step with every refresh. + # Seed the header slot for whichever mode ran — see _AuthManager.install_header. auth_manager.install_header(configuration) # ------------------------------------------------------------------ diff --git a/tests/_jwt.py b/tests/_jwt.py new file mode 100644 index 00000000..b02e07b8 --- /dev/null +++ b/tests/_jwt.py @@ -0,0 +1,48 @@ +""" +JWT factories shared by the auth and client tests. + +Underscore-prefixed so pytest does not collect it as a test module. Stdlib only — +it must not import common/ or any tb_*_client package, so either side can use it. +""" + +import base64 +import json +import time + + +def _make_jwt(claims: dict) -> str: + """Create a minimal 3-part JWT (header.payload.signature) for testing. + + The header and signature are stubs — only the payload is meaningful. + """ + header = ( + base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + .rstrip(b"=") + .decode() + ) + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() + signature = "fakesig" + return f"{header}.{payload}.{signature}" + + +def _make_token(exp_offset_s: int, iat_offset_s: int = 0) -> str: + """Create a JWT with exp = now + exp_offset_s and iat = now + iat_offset_s.""" + now = int(time.time()) + return _make_jwt( + { + "exp": now + exp_offset_s, + "iat": now + iat_offset_s, + "sub": "user@example.com", + } + ) + + +def _make_refresh_token(exp_offset_s: int) -> str: + """Create a JWT with exp = now + exp_offset_s (for refresh tokens).""" + now = int(time.time()) + return _make_jwt( + { + "exp": now + exp_offset_s, + "sub": "user@example.com", + } + ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 34c6debe..8cdc49da 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,57 +3,18 @@ Covers AUTH-01 through AUTH-06 requirements. """ -import base64 -import json import time import unittest from unittest.mock import MagicMock, patch from common._auth import _AuthManager, _parse_jwt_claim_ms +from tests._jwt import _make_jwt, _make_refresh_token, _make_token # --------------------------------------------------------------------------- # Test helpers # --------------------------------------------------------------------------- -def _make_jwt(claims: dict) -> str: - """Create a minimal 3-part JWT (header.payload.signature) for testing. - - The header and signature are stubs — only the payload is meaningful. - """ - header = ( - base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) - .rstrip(b"=") - .decode() - ) - payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() - signature = "fakesig" - return f"{header}.{payload}.{signature}" - - -def _make_token(exp_offset_s: int, iat_offset_s: int = 0) -> str: - """Create a JWT with exp = now + exp_offset_s and iat = now + iat_offset_s.""" - now = int(time.time()) - return _make_jwt( - { - "exp": now + exp_offset_s, - "iat": now + iat_offset_s, - "sub": "user@example.com", - } - ) - - -def _make_refresh_token(exp_offset_s: int) -> str: - """Create a JWT with exp = now + exp_offset_s (for refresh tokens).""" - now = int(time.time()) - return _make_jwt( - { - "exp": now + exp_offset_s, - "sub": "user@example.com", - } - ) - - def _mock_configuration(): """Return a minimal mock Configuration object.""" config = MagicMock() diff --git a/tests/test_client.py b/tests/test_client.py index 4217d173..642d1e20 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,12 +9,10 @@ from unittest.mock import ANY, MagicMock, patch from tb_ce_client._retry import _RetryingRESTClient - -# conftest.py handles sys.path; this import will work once client.py is copied from tb_ce_client.client import ThingsboardClient from tb_ce_client.rest import RESTClientObject -from tests.test_auth import _make_refresh_token, _make_token +from tests._jwt import _make_refresh_token, _make_token URL = "http://tb-server:9090" @@ -51,12 +49,7 @@ def test_jwt_login(self): self.assertEqual(client.get_token(), mock_resp.token) def test_jwt_login_emits_x_authorization_header(self): - """AUTH-01: auth_settings() yields the header an API request actually sends. - - auth_settings() only emits the header when 'ApiKeyForm' is already present - in configuration.api_key, so the slot has to be seeded before the refresh - hook — which runs inside that same check — can ever take over. - """ + """AUTH-01: auth_settings() yields the header an API request actually sends.""" client = _logged_in_client() auth = client.api_client.configuration.auth_settings() self.assertIn("ApiKeyForm", auth) @@ -99,6 +92,20 @@ def test_preexisting_token(self): mock_login.assert_not_called() cfg = client.api_client.configuration self.assertEqual(cfg.api_key.get("ApiKeyForm"), "jwt.payload.sig") + self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") + + def test_no_auth_leaves_header_slot_absent(self): + """A client built without auth kwargs creates no ApiKeyForm slot. + + Legitimate for the /api/noauth endpoints: construction must not raise, and + auth_settings() must stay empty so no X-Authorization header is sent. + """ + with patch(_LOGIN_PATCH_TARGET) as mock_login: + client = ThingsboardClient(URL) + mock_login.assert_not_called() + cfg = client.api_client.configuration + self.assertNotIn("ApiKeyForm", cfg.api_key) + self.assertEqual(cfg.auth_settings(), {}) class TestThingsboardClientAuthArgValidation(unittest.TestCase): @@ -112,21 +119,39 @@ def test_api_key_with_username_rejected(self): failing with 401 once it expired. """ with patch(_LOGIN_PATCH_TARGET) as mock_login: - with self.assertRaises(ValueError): + with self.assertRaisesRegex(ValueError, "username, api_key"): ThingsboardClient(URL, "user@tb.io", "pass123", api_key="test-key") mock_login.assert_not_called() def test_api_key_with_token_rejected(self): """api_key= plus token= raises.""" - with self.assertRaises(ValueError): + with self.assertRaisesRegex(ValueError, "api_key, token"): ThingsboardClient(URL, api_key="test-key", token="jwt.payload.sig") def test_username_with_token_rejected(self): """username= plus token= raises.""" with patch(_LOGIN_PATCH_TARGET): - with self.assertRaises(ValueError): + with self.assertRaisesRegex(ValueError, "username, token"): ThingsboardClient(URL, "user@tb.io", "pass123", token="jwt.payload.sig") + def test_all_three_modes_rejected(self): + """All three at once raises and the message names every colliding mode.""" + with patch(_LOGIN_PATCH_TARGET): + with self.assertRaisesRegex(ValueError, "username, api_key, token"): + ThingsboardClient( + URL, "user@tb.io", "pass123", api_key="test-key", token="jwt.payload.sig" + ) + + def test_password_without_username_rejected(self): + """password= alone would be silently dropped, so it raises instead.""" + with self.assertRaisesRegex(ValueError, "password= requires username="): + ThingsboardClient(URL, password="pass123") + + def test_refresh_token_without_token_rejected(self): + """refresh_token= alone would be silently dropped, so it raises instead.""" + with self.assertRaisesRegex(ValueError, "refresh_token= requires token="): + ThingsboardClient(URL, api_key="test-key", refresh_token="jwt.payload.sig") + class TestThingsboardClientStructure(unittest.TestCase): """WRAP-02: ThingsboardClient has api_client and _auth_manager attributes.""" diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 5b56fac0..7594482a 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -1,10 +1,13 @@ """ Guards the common/ -> edition overlay performed by generate-client.sh. -generate-client.sh copies common/*.py verbatim into every tb__client/ -package, and those copies are committed. Nothing else in CI compares them, so a -fix landed in common/ but overlaid into only some editions would ship stale code -to the rest. These tests fail on exactly that. +generate-client.sh copies common/ verbatim into every tb__client/ package, +and those copies are committed. Nothing else in CI compares them, so a fix landed in +common/ but overlaid into only some editions would ship stale code to the rest. + +Both the module list and the edition list are derived from the repo rather than +hardcoded, so adding either a file to common/ or a new edition directory extends the +check automatically. """ from pathlib import Path @@ -13,34 +16,43 @@ _REPO_ROOT = Path(__file__).parent.parent -# Hand-written modules overlaid verbatim. __init__.py is excluded: post_process.py -# merges it with the generated package __init__, so the copies legitimately differ. -_OVERLAID_MODULES = ["client.py", "_auth.py", "_retry.py"] +# Entries in common/ that are deliberately NOT byte-identical in the editions: +# docs — generate-client.sh overlays it into /docs, not the package +# __init__.py — post_process.py merges it with the generated package __init__ +# __pycache__ — build output, never committed +_NOT_OVERLAID = {"docs", "__init__.py", "__pycache__"} + + +def _overlaid_files() -> list[str]: + """Names in common/ that must appear verbatim in every edition package.""" + return sorted(p.name for p in (_REPO_ROOT / "common").iterdir() if p.name not in _NOT_OVERLAID) + + +def _editions() -> list[str]: + """Edition directory names, discovered from the committed /tb_*_client/ dirs.""" + return sorted(p.parent.name for p in _REPO_ROOT.glob("*/tb_*_client") if p.is_dir()) + -_EDITIONS = ["ce", "pe", "paas"] +def test_discovery_finds_files_and_editions(): + """Both derived lists are non-empty. + + Without this, a glob that silently matched nothing would collect zero + parametrized cases and the sync check would vacuously pass. + """ + assert _overlaid_files(), "no overlaid modules discovered in common/" + assert _editions(), "no /tb_*_client/ directories discovered" -@pytest.mark.parametrize("edition", _EDITIONS) -@pytest.mark.parametrize("module", _OVERLAID_MODULES) -def test_edition_copy_matches_common(edition, module): +@pytest.mark.parametrize("edition", _editions()) +@pytest.mark.parametrize("name", _overlaid_files()) +def test_edition_copy_matches_common(edition, name): """Each committed edition copy is byte-identical to its common/ source.""" - source = _REPO_ROOT / "common" / module - copy = _REPO_ROOT / edition / f"tb_{edition}_client" / module + source = _REPO_ROOT / "common" / name + copy = _REPO_ROOT / edition / f"tb_{edition}_client" / name assert copy.exists(), f"{copy} is missing — run generate-client.sh" assert copy.read_bytes() == source.read_bytes(), ( - f"{copy.relative_to(_REPO_ROOT)} is out of sync with common/{module}. " - f"Edit common/{module} and re-run generate-client.sh (or copy it into " + f"{copy.relative_to(_REPO_ROOT)} is out of sync with common/{name}. " + f"Edit common/{name} and re-run generate-client.sh (or copy it into " f"every tb_*_client/ package)." ) - - -def test_all_common_modules_are_covered(): - """_OVERLAID_MODULES lists every hand-written module in common/. - - Without this, adding a new file to common/ would silently escape the sync check. - """ - present = { - path.name for path in (_REPO_ROOT / "common").glob("*.py") if path.name != "__init__.py" - } - assert present == set(_OVERLAID_MODULES) From d52c7251ffa46d9f9ef4d28d91f3cdc2d09db683 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 12:24:34 +0300 Subject: [PATCH 04/17] Address third review: symmetric auth validation, pin editions, tidy shared helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject username= without password=; LoginRequest.password is a required StrictStr, so it previously surfaced as a pydantic ValidationError from inside the generated model instead of the ValueError every other bad combo raises. - Document the no-auth mode in the class docstring and README — it was only discoverable from a test after the last round added one. - Hoist the auth-mode decision to self._is_api_key and use it for all three branches; _auth_type is gone, so an unexpected value can no longer read as jwt in two places and api_key in a third. Keep _header_prefix off the hook path. - Parse editions from generate-client.sh's EDITIONS array instead of globbing package dirs: a renamed or missing edition directory now fails rather than quietly shrinking the parametrization. Walk common/ recursively and skip non-files, so a subdirectory is covered instead of raising IsADirectoryError. - Drop the leading underscore from the tests/_jwt.py factories, and make the refresh-token test bare so it matches its docstring. --- README.md | 11 +++++++ ce/tb_ce_client/_auth.py | 14 ++++---- ce/tb_ce_client/client.py | 6 ++++ common/_auth.py | 14 ++++---- common/client.py | 6 ++++ paas/tb_paas_client/_auth.py | 14 ++++---- paas/tb_paas_client/client.py | 6 ++++ pe/tb_pe_client/_auth.py | 14 ++++---- pe/tb_pe_client/client.py | 6 ++++ tests/_jwt.py | 16 ++++++---- tests/test_auth.py | 48 ++++++++++++++-------------- tests/test_client.py | 19 +++++++---- tests/test_common_overlay.py | 60 +++++++++++++++++++++++------------ 13 files changed, 154 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index efad2267..e36abb3f 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,17 @@ client = ThingsboardClient( ) ``` +**No authentication:** All auth arguments are optional. Omit them to get a client that +sends no `X-Authorization` header, for use with the `/api/noauth` endpoints. + +```python +client = ThingsboardClient("http://localhost:9090") +``` + +The three authenticated modes are mutually exclusive — passing more than one raises +`ValueError`, as does passing `password=`, `refresh_token=` or `username=` without its +companion argument. + ## Resource cleanup Use the client as a context manager so `close()` is called automatically on exit: diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 81fda81e..953a4b5f 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -122,16 +122,18 @@ def __init__(self, base_url: str, auth_type: str, api_key=None): api_key: The API key string when auth_type='api_key', else None. """ self._base_url = base_url.rstrip("/") - self._auth_type = auth_type - # The header prefix follows from auth_type and never changes afterwards, - # so resolve it once here instead of on every install_header() call. - self._header_prefix = _API_KEY_PREFIX if auth_type == "api_key" else _JWT_PREFIX + # Resolve the auth mode once: it never changes, and every later decision + # (initial token state, header prefix, whether the hook refreshes) follows + # from it. Keeping the string comparison here means an unexpected auth_type + # can't be read as api_key by one branch and jwt by another. + self._is_api_key = auth_type == "api_key" + self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False self._username = None self._password = None - if auth_type == "api_key": + if self._is_api_key: self._token_info = _TokenInfo(api_key, None, -1, -1, 0) else: self._token_info = _TokenInfo.EMPTY # type: ignore[attr-defined] @@ -180,7 +182,7 @@ def hook(self, configuration) -> None: every API request assembles its X-Authorization header. Checks token expiry and refreshes if needed, then updates configuration.api_key. """ - if self._auth_type != "jwt": + if self._is_api_key: # API key auth — hook is a no-op; the key is set at construction time return self._refresh_if_needed() diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index f6f8fcbc..94e220c0 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -54,6 +54,8 @@ class ThingsboardClient: Injects an externally obtained JWT; no login call made. The three modes are mutually exclusive — passing more than one raises ValueError. + All auth arguments are optional: omitting them yields an unauthenticated client + that sends no X-Authorization header, which is what the /api/noauth endpoints want. Context manager usage: with ThingsboardClient(url, api_key="key") as client: @@ -116,6 +118,10 @@ def __init__( raise ValueError("password= requires username=") if refresh_token is not None and token is None: raise ValueError("refresh_token= requires token=") + # LoginRequest.password is a required StrictStr, so without this the caller + # gets a pydantic ValidationError from inside the generated model instead. + if username is not None and password is None: + raise ValueError("username= requires password=") configuration = Configuration(host=url) diff --git a/common/_auth.py b/common/_auth.py index 81fda81e..953a4b5f 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -122,16 +122,18 @@ def __init__(self, base_url: str, auth_type: str, api_key=None): api_key: The API key string when auth_type='api_key', else None. """ self._base_url = base_url.rstrip("/") - self._auth_type = auth_type - # The header prefix follows from auth_type and never changes afterwards, - # so resolve it once here instead of on every install_header() call. - self._header_prefix = _API_KEY_PREFIX if auth_type == "api_key" else _JWT_PREFIX + # Resolve the auth mode once: it never changes, and every later decision + # (initial token state, header prefix, whether the hook refreshes) follows + # from it. Keeping the string comparison here means an unexpected auth_type + # can't be read as api_key by one branch and jwt by another. + self._is_api_key = auth_type == "api_key" + self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False self._username = None self._password = None - if auth_type == "api_key": + if self._is_api_key: self._token_info = _TokenInfo(api_key, None, -1, -1, 0) else: self._token_info = _TokenInfo.EMPTY # type: ignore[attr-defined] @@ -180,7 +182,7 @@ def hook(self, configuration) -> None: every API request assembles its X-Authorization header. Checks token expiry and refreshes if needed, then updates configuration.api_key. """ - if self._auth_type != "jwt": + if self._is_api_key: # API key auth — hook is a no-op; the key is set at construction time return self._refresh_if_needed() diff --git a/common/client.py b/common/client.py index f6f8fcbc..94e220c0 100644 --- a/common/client.py +++ b/common/client.py @@ -54,6 +54,8 @@ class ThingsboardClient: Injects an externally obtained JWT; no login call made. The three modes are mutually exclusive — passing more than one raises ValueError. + All auth arguments are optional: omitting them yields an unauthenticated client + that sends no X-Authorization header, which is what the /api/noauth endpoints want. Context manager usage: with ThingsboardClient(url, api_key="key") as client: @@ -116,6 +118,10 @@ def __init__( raise ValueError("password= requires username=") if refresh_token is not None and token is None: raise ValueError("refresh_token= requires token=") + # LoginRequest.password is a required StrictStr, so without this the caller + # gets a pydantic ValidationError from inside the generated model instead. + if username is not None and password is None: + raise ValueError("username= requires password=") configuration = Configuration(host=url) diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 81fda81e..953a4b5f 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -122,16 +122,18 @@ def __init__(self, base_url: str, auth_type: str, api_key=None): api_key: The API key string when auth_type='api_key', else None. """ self._base_url = base_url.rstrip("/") - self._auth_type = auth_type - # The header prefix follows from auth_type and never changes afterwards, - # so resolve it once here instead of on every install_header() call. - self._header_prefix = _API_KEY_PREFIX if auth_type == "api_key" else _JWT_PREFIX + # Resolve the auth mode once: it never changes, and every later decision + # (initial token state, header prefix, whether the hook refreshes) follows + # from it. Keeping the string comparison here means an unexpected auth_type + # can't be read as api_key by one branch and jwt by another. + self._is_api_key = auth_type == "api_key" + self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False self._username = None self._password = None - if auth_type == "api_key": + if self._is_api_key: self._token_info = _TokenInfo(api_key, None, -1, -1, 0) else: self._token_info = _TokenInfo.EMPTY # type: ignore[attr-defined] @@ -180,7 +182,7 @@ def hook(self, configuration) -> None: every API request assembles its X-Authorization header. Checks token expiry and refreshes if needed, then updates configuration.api_key. """ - if self._auth_type != "jwt": + if self._is_api_key: # API key auth — hook is a no-op; the key is set at construction time return self._refresh_if_needed() diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index f6f8fcbc..94e220c0 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -54,6 +54,8 @@ class ThingsboardClient: Injects an externally obtained JWT; no login call made. The three modes are mutually exclusive — passing more than one raises ValueError. + All auth arguments are optional: omitting them yields an unauthenticated client + that sends no X-Authorization header, which is what the /api/noauth endpoints want. Context manager usage: with ThingsboardClient(url, api_key="key") as client: @@ -116,6 +118,10 @@ def __init__( raise ValueError("password= requires username=") if refresh_token is not None and token is None: raise ValueError("refresh_token= requires token=") + # LoginRequest.password is a required StrictStr, so without this the caller + # gets a pydantic ValidationError from inside the generated model instead. + if username is not None and password is None: + raise ValueError("username= requires password=") configuration = Configuration(host=url) diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 81fda81e..953a4b5f 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -122,16 +122,18 @@ def __init__(self, base_url: str, auth_type: str, api_key=None): api_key: The API key string when auth_type='api_key', else None. """ self._base_url = base_url.rstrip("/") - self._auth_type = auth_type - # The header prefix follows from auth_type and never changes afterwards, - # so resolve it once here instead of on every install_header() call. - self._header_prefix = _API_KEY_PREFIX if auth_type == "api_key" else _JWT_PREFIX + # Resolve the auth mode once: it never changes, and every later decision + # (initial token state, header prefix, whether the hook refreshes) follows + # from it. Keeping the string comparison here means an unexpected auth_type + # can't be read as api_key by one branch and jwt by another. + self._is_api_key = auth_type == "api_key" + self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False self._username = None self._password = None - if auth_type == "api_key": + if self._is_api_key: self._token_info = _TokenInfo(api_key, None, -1, -1, 0) else: self._token_info = _TokenInfo.EMPTY # type: ignore[attr-defined] @@ -180,7 +182,7 @@ def hook(self, configuration) -> None: every API request assembles its X-Authorization header. Checks token expiry and refreshes if needed, then updates configuration.api_key. """ - if self._auth_type != "jwt": + if self._is_api_key: # API key auth — hook is a no-op; the key is set at construction time return self._refresh_if_needed() diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index f6f8fcbc..94e220c0 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -54,6 +54,8 @@ class ThingsboardClient: Injects an externally obtained JWT; no login call made. The three modes are mutually exclusive — passing more than one raises ValueError. + All auth arguments are optional: omitting them yields an unauthenticated client + that sends no X-Authorization header, which is what the /api/noauth endpoints want. Context manager usage: with ThingsboardClient(url, api_key="key") as client: @@ -116,6 +118,10 @@ def __init__( raise ValueError("password= requires username=") if refresh_token is not None and token is None: raise ValueError("refresh_token= requires token=") + # LoginRequest.password is a required StrictStr, so without this the caller + # gets a pydantic ValidationError from inside the generated model instead. + if username is not None and password is None: + raise ValueError("username= requires password=") configuration = Configuration(host=url) diff --git a/tests/_jwt.py b/tests/_jwt.py index b02e07b8..f2168ec4 100644 --- a/tests/_jwt.py +++ b/tests/_jwt.py @@ -10,7 +10,7 @@ import time -def _make_jwt(claims: dict) -> str: +def make_jwt(claims: dict) -> str: """Create a minimal 3-part JWT (header.payload.signature) for testing. The header and signature are stubs — only the payload is meaningful. @@ -25,10 +25,10 @@ def _make_jwt(claims: dict) -> str: return f"{header}.{payload}.{signature}" -def _make_token(exp_offset_s: int, iat_offset_s: int = 0) -> str: +def make_token(exp_offset_s: int, iat_offset_s: int = 0) -> str: """Create a JWT with exp = now + exp_offset_s and iat = now + iat_offset_s.""" now = int(time.time()) - return _make_jwt( + return make_jwt( { "exp": now + exp_offset_s, "iat": now + iat_offset_s, @@ -37,10 +37,14 @@ def _make_token(exp_offset_s: int, iat_offset_s: int = 0) -> str: ) -def _make_refresh_token(exp_offset_s: int) -> str: - """Create a JWT with exp = now + exp_offset_s (for refresh tokens).""" +def make_refresh_token(exp_offset_s: int) -> str: + """Create a refresh JWT with exp = now + exp_offset_s. + + Kept separate from make_token because ThingsBoard refresh tokens carry no iat + claim — _build_token_info() reads iat only from the access token. + """ now = int(time.time()) - return _make_jwt( + return make_jwt( { "exp": now + exp_offset_s, "sub": "user@example.com", diff --git a/tests/test_auth.py b/tests/test_auth.py index 8cdc49da..8dd8290c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch from common._auth import _AuthManager, _parse_jwt_claim_ms -from tests._jwt import _make_jwt, _make_refresh_token, _make_token +from tests._jwt import make_jwt, make_refresh_token, make_token # --------------------------------------------------------------------------- # Test helpers @@ -32,14 +32,14 @@ class TestParseJwtClaimMs(unittest.TestCase): def test_parse_exp_claim(self): """_parse_jwt_claim_ms returns exp * 1000 for a valid JWT.""" exp_seconds = int(time.time()) + 3600 - jwt = _make_jwt({"exp": exp_seconds, "iat": int(time.time())}) + jwt = make_jwt({"exp": exp_seconds, "iat": int(time.time())}) result = _parse_jwt_claim_ms(jwt, "exp") self.assertEqual(result, exp_seconds * 1000) def test_parse_iat_claim(self): """_parse_jwt_claim_ms returns iat * 1000 for the iat claim.""" iat_seconds = int(time.time()) - jwt = _make_jwt({"exp": iat_seconds + 3600, "iat": iat_seconds}) + jwt = make_jwt({"exp": iat_seconds + 3600, "iat": iat_seconds}) result = _parse_jwt_claim_ms(jwt, "iat") self.assertEqual(result, iat_seconds * 1000) @@ -51,7 +51,7 @@ def test_invalid_jwt_returns_minus_one(self): def test_missing_claim_returns_minus_one(self): """_parse_jwt_claim_ms returns -1 when the claim key is absent.""" - jwt = _make_jwt({"sub": "user@example.com"}) + jwt = make_jwt({"sub": "user@example.com"}) self.assertEqual(_parse_jwt_claim_ms(jwt, "exp"), -1) def test_malformed_base64_returns_minus_one(self): @@ -68,8 +68,8 @@ class TestJwtLogin(unittest.TestCase): def test_jwt_login(self): """on_login stores credentials and builds _TokenInfo from provided JWTs.""" auth = _AuthManager("http://tb:9090", "jwt", None) - token = _make_token(exp_offset_s=3600, iat_offset_s=0) - refresh = _make_refresh_token(exp_offset_s=86400) + token = make_token(exp_offset_s=3600, iat_offset_s=0) + refresh = make_refresh_token(exp_offset_s=86400) auth.on_login("user@tb.io", "password", token, refresh) @@ -93,13 +93,13 @@ def test_hook_refreshes_expired_token(self): """hook() calls /api/auth/token when access token is expired but refresh is valid.""" auth = _AuthManager("http://tb:9090", "jwt", None) # expired access token (exp in the past) - old_token = _make_token(exp_offset_s=-3600, iat_offset_s=0) + old_token = make_token(exp_offset_s=-3600, iat_offset_s=0) # valid refresh token - refresh = _make_refresh_token(exp_offset_s=86400) + refresh = make_refresh_token(exp_offset_s=86400) auth.on_login("user@tb.io", "password", old_token, refresh) - new_token = _make_token(exp_offset_s=7200, iat_offset_s=0) - new_refresh = _make_refresh_token(exp_offset_s=172800) + new_token = make_token(exp_offset_s=7200, iat_offset_s=0) + new_refresh = make_refresh_token(exp_offset_s=172800) new_response_data = {"token": new_token, "refreshToken": new_refresh} with patch.object(auth, "_raw_post", return_value=new_response_data) as mock_post: @@ -114,8 +114,8 @@ def test_hook_skips_when_token_valid(self): """hook() does not call HTTP when access token is still valid.""" auth = _AuthManager("http://tb:9090", "jwt", None) # token valid for an hour - token = _make_token(exp_offset_s=3600, iat_offset_s=0) - refresh = _make_refresh_token(exp_offset_s=86400) + token = make_token(exp_offset_s=3600, iat_offset_s=0) + refresh = make_refresh_token(exp_offset_s=86400) auth.on_login("user@tb.io", "password", token, refresh) with patch.object(auth, "_raw_post") as mock_post: @@ -138,8 +138,8 @@ def test_clock_skew_compensation(self): auth = _AuthManager("http://tb:9090", "jwt", None) # iat is 5 seconds ahead of "now" (simulates server clock being 5s ahead) skew_s = 5 - token = _make_token(exp_offset_s=skew_s + 35, iat_offset_s=skew_s) - refresh = _make_refresh_token(exp_offset_s=86400) + token = make_token(exp_offset_s=skew_s + 35, iat_offset_s=skew_s) + refresh = make_refresh_token(exp_offset_s=86400) auth.on_login("user@tb.io", "password", token, refresh) @@ -165,12 +165,12 @@ class TestReloginOnRefreshExpiry(unittest.TestCase): def test_relogin_on_refresh_expiry(self): """hook() calls /api/auth/login when both access and refresh tokens are expired.""" auth = _AuthManager("http://tb:9090", "jwt", None) - expired_token = _make_token(exp_offset_s=-7200, iat_offset_s=0) - expired_refresh = _make_refresh_token(exp_offset_s=-3600) + expired_token = make_token(exp_offset_s=-7200, iat_offset_s=0) + expired_refresh = make_refresh_token(exp_offset_s=-3600) auth.on_login("user@tb.io", "password", expired_token, expired_refresh) - new_token = _make_token(exp_offset_s=3600, iat_offset_s=0) - new_refresh = _make_refresh_token(exp_offset_s=86400) + new_token = make_token(exp_offset_s=3600, iat_offset_s=0) + new_refresh = make_refresh_token(exp_offset_s=86400) new_response_data = {"token": new_token, "refreshToken": new_refresh} with patch.object(auth, "_raw_post", return_value=new_response_data) as mock_post: @@ -184,12 +184,12 @@ def test_refresh_failure_falls_back_to_relogin(self): """When refresh fails, _do_login is called as fallback (AUTH-04).""" auth = _AuthManager("http://tb:9090", "jwt", None) # expired access token, but valid refresh token so _do_refresh_token will be tried first - expired_token = _make_token(exp_offset_s=-7200, iat_offset_s=0) - valid_refresh = _make_refresh_token(exp_offset_s=86400) + expired_token = make_token(exp_offset_s=-7200, iat_offset_s=0) + valid_refresh = make_refresh_token(exp_offset_s=86400) auth.on_login("user@tb.io", "password", expired_token, valid_refresh) - new_token = _make_token(exp_offset_s=3600, iat_offset_s=0) - new_refresh = _make_refresh_token(exp_offset_s=86400) + new_token = make_token(exp_offset_s=3600, iat_offset_s=0) + new_refresh = make_refresh_token(exp_offset_s=86400) new_response_data = {"token": new_token, "refreshToken": new_refresh} call_count = [0] @@ -241,8 +241,8 @@ def test_preexisting_token(self): """set_external_token parses exp times correctly from provided JWTs.""" auth = _AuthManager("http://tb:9090", "jwt", None) now_s = int(time.time()) - token = _make_jwt({"exp": now_s + 3600, "iat": now_s}) - refresh = _make_jwt({"exp": now_s + 86400}) + token = make_jwt({"exp": now_s + 3600, "iat": now_s}) + refresh = make_jwt({"exp": now_s + 86400}) auth.set_external_token(token, refresh) diff --git a/tests/test_client.py b/tests/test_client.py index 642d1e20..b7c5756b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,7 +12,7 @@ from tb_ce_client.client import ThingsboardClient from tb_ce_client.rest import RESTClientObject -from tests._jwt import _make_refresh_token, _make_token +from tests._jwt import make_refresh_token, make_token URL = "http://tb-server:9090" @@ -64,11 +64,11 @@ def test_jwt_header_follows_token_rotation(self): through a real /api/auth/token refresh before the header is assembled. """ client = _logged_in_client( - token=_make_token(exp_offset_s=-3600), - refresh_token=_make_refresh_token(exp_offset_s=86400), + token=make_token(exp_offset_s=-3600), + refresh_token=make_refresh_token(exp_offset_s=86400), ) - rotated = _make_token(exp_offset_s=3600) - refreshed = {"token": rotated, "refreshToken": _make_refresh_token(exp_offset_s=172800)} + rotated = make_token(exp_offset_s=3600) + refreshed = {"token": rotated, "refreshToken": make_refresh_token(exp_offset_s=172800)} with patch.object(client._auth_manager, "_raw_post", return_value=refreshed) as mock_post: auth = client.api_client.configuration.auth_settings() mock_post.assert_called_once_with("/api/auth/token", ANY) @@ -150,7 +150,14 @@ def test_password_without_username_rejected(self): def test_refresh_token_without_token_rejected(self): """refresh_token= alone would be silently dropped, so it raises instead.""" with self.assertRaisesRegex(ValueError, "refresh_token= requires token="): - ThingsboardClient(URL, api_key="test-key", refresh_token="jwt.payload.sig") + ThingsboardClient(URL, refresh_token="jwt.payload.sig") + + def test_username_without_password_rejected(self): + """username= alone raises here rather than as a pydantic error from LoginRequest.""" + with patch(_LOGIN_PATCH_TARGET) as mock_login: + with self.assertRaisesRegex(ValueError, "username= requires password="): + ThingsboardClient(URL, "user@tb.io") + mock_login.assert_not_called() class TestThingsboardClientStructure(unittest.TestCase): diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 7594482a..8b0c7ac0 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -5,54 +5,74 @@ and those copies are committed. Nothing else in CI compares them, so a fix landed in common/ but overlaid into only some editions would ship stale code to the rest. -Both the module list and the edition list are derived from the repo rather than -hardcoded, so adding either a file to common/ or a new edition directory extends the -check automatically. +Both lists are taken from the things that define them rather than hardcoded here: +filenames from common/ itself, editions from generate-client.sh. Adding either a file +or an edition extends the check with no test edit — and, because the editions come +from the script rather than from whichever directories happen to exist, an edition +whose package directory is missing fails instead of quietly dropping out. """ +import re from pathlib import Path import pytest _REPO_ROOT = Path(__file__).parent.parent +_COMMON_DIR = _REPO_ROOT / "common" # Entries in common/ that are deliberately NOT byte-identical in the editions: # docs — generate-client.sh overlays it into /docs, not the package # __init__.py — post_process.py merges it with the generated package __init__ # __pycache__ — build output, never committed -_NOT_OVERLAID = {"docs", "__init__.py", "__pycache__"} +_EXCLUDED_TOP_LEVEL = {"docs", "__init__.py"} +_EXCLUDED_DIRS = {"__pycache__"} -def _overlaid_files() -> list[str]: - """Names in common/ that must appear verbatim in every edition package.""" - return sorted(p.name for p in (_REPO_ROOT / "common").iterdir() if p.name not in _NOT_OVERLAID) +def _overlaid_filenames() -> list[str]: + """Paths under common/ that must appear verbatim in every edition package. + + Walks recursively and returns paths relative to common/, because + generate-client.sh `cp -r`s every entry — subdirectories included. + """ + names = [] + for path in _COMMON_DIR.rglob("*"): + if not path.is_file(): + continue + rel = path.relative_to(_COMMON_DIR) + if rel.parts[0] in _EXCLUDED_TOP_LEVEL or _EXCLUDED_DIRS.intersection(rel.parts): + continue + names.append(str(rel)) + return sorted(names) def _editions() -> list[str]: - """Edition directory names, discovered from the committed /tb_*_client/ dirs.""" - return sorted(p.parent.name for p in _REPO_ROOT.glob("*/tb_*_client") if p.is_dir()) + """Edition names parsed from the EDITIONS array in generate-client.sh.""" + script = (_REPO_ROOT / "generate-client.sh").read_text(encoding="utf-8") + match = re.search(r"^EDITIONS=\(([^)]*)\)", script, re.MULTILINE) + assert match, "could not find the EDITIONS=(...) array in generate-client.sh" + return sorted(re.findall(r'"([^"]+)"', match.group(1))) -def test_discovery_finds_files_and_editions(): +def test_discovery_finds_filenames_and_editions(): """Both derived lists are non-empty. - Without this, a glob that silently matched nothing would collect zero + Without this, a glob or regex that silently matched nothing would collect zero parametrized cases and the sync check would vacuously pass. """ - assert _overlaid_files(), "no overlaid modules discovered in common/" - assert _editions(), "no /tb_*_client/ directories discovered" + assert _overlaid_filenames(), "no overlaid files discovered in common/" + assert _editions(), "no editions parsed from generate-client.sh" @pytest.mark.parametrize("edition", _editions()) -@pytest.mark.parametrize("name", _overlaid_files()) -def test_edition_copy_matches_common(edition, name): +@pytest.mark.parametrize("filename", _overlaid_filenames()) +def test_edition_copy_matches_common(edition, filename): """Each committed edition copy is byte-identical to its common/ source.""" - source = _REPO_ROOT / "common" / name - copy = _REPO_ROOT / edition / f"tb_{edition}_client" / name + source = _COMMON_DIR / filename + copy = _REPO_ROOT / edition / f"tb_{edition}_client" / filename - assert copy.exists(), f"{copy} is missing — run generate-client.sh" + assert copy.is_file(), f"{copy} is missing — run generate-client.sh" assert copy.read_bytes() == source.read_bytes(), ( - f"{copy.relative_to(_REPO_ROOT)} is out of sync with common/{name}. " - f"Edit common/{name} and re-run generate-client.sh (or copy it into " + f"{copy.relative_to(_REPO_ROOT)} is out of sync with common/{filename}. " + f"Edit common/{filename} and re-run generate-client.sh (or copy it into " f"every tb_*_client/ package)." ) From c304eeda9e9c9a187bb90b87eac36db1bb46b8cb Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 12:46:02 +0300 Subject: [PATCH 05/17] Address fourth review: drop vestigial auth_type, sync docstrings, document all modes - Remove the auth_type parameter from _AuthManager: nothing read the string once _is_api_key landed, so the mode was encoded at the one call site and decoded straight back, and 'jwt' had become 'anything that isn't the literal api_key'. It now derives the mode from api_key directly, so no spelling of a mode name can silently mean something else. - Update the __init__ Raises: block for the username=/password= guard added last round, and say 'three modes, plus unauthenticated' now that a fourth is listed. - Document the pre-existing-token and no-auth modes in common/docs/tb-examples.md, which is overlaid into every edition's docs/ and covered only two of four. - Note in generate-client.sh that test_common_overlay.py parses EDITIONS, so the formatting the regex depends on is stated where people edit it. - Split the overlay exclusion comment so each rationale sits with its own set, and add test_walk_exclusion_semantics: common/ is flat today, so nothing real exercised the recursion or the any-depth filter. --- ce/docs/tb-examples.md | 29 ++++++++++++++++++++++ ce/tb_ce_client/_auth.py | 20 +++++++-------- ce/tb_ce_client/client.py | 10 ++++---- common/_auth.py | 20 +++++++-------- common/client.py | 10 ++++---- common/docs/tb-examples.md | 29 ++++++++++++++++++++++ generate-client.sh | 2 ++ paas/docs/tb-examples.md | 29 ++++++++++++++++++++++ paas/tb_paas_client/_auth.py | 20 +++++++-------- paas/tb_paas_client/client.py | 10 ++++---- pe/docs/tb-examples.md | 29 ++++++++++++++++++++++ pe/tb_pe_client/_auth.py | 20 +++++++-------- pe/tb_pe_client/client.py | 10 ++++---- tests/test_auth.py | 16 ++++++------ tests/test_common_overlay.py | 46 ++++++++++++++++++++++++++++------- 15 files changed, 223 insertions(+), 77 deletions(-) diff --git a/ce/docs/tb-examples.md b/ce/docs/tb-examples.md index 0af4f168..ccd176df 100644 --- a/ce/docs/tb-examples.md +++ b/ce/docs/tb-examples.md @@ -23,6 +23,35 @@ from tb_ce_client import ThingsboardClient client = ThingsboardClient("http://localhost:9090", api_key="your-api-key") ``` +## Pre-existing Token + +Injects an externally obtained JWT; no login call is made. + +```python +from tb_ce_client import ThingsboardClient + +client = ThingsboardClient( + "http://localhost:9090", + token="eyJhbGciOi...", + refresh_token="eyJhbGciOi...", +) +``` + +## No Authentication + +All auth arguments are optional. Omit them for a client that sends no +`X-Authorization` header, for use with the `/api/noauth` endpoints. + +```python +from tb_ce_client import ThingsboardClient + +client = ThingsboardClient("http://localhost:9090") +``` + +The three authenticated modes above are mutually exclusive — passing more than one +raises `ValueError`, as does passing `username=`, `password=`, `token=` or +`refresh_token=` without its companion argument. + ## Context Manager ```python diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 953a4b5f..be8580b3 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -113,20 +113,20 @@ class _AuthManager: lock and skip the refresh once the first thread completes. """ - def __init__(self, base_url: str, auth_type: str, api_key=None): + def __init__(self, base_url: str, api_key=None): """ Args: - base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). - Trailing slashes are stripped. - auth_type: Either 'jwt' (username/password or token) or 'api_key'. - api_key: The API key string when auth_type='api_key', else None. + base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). + Trailing slashes are stripped. + api_key: The API key string for API key auth, or None for JWT auth + (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # Resolve the auth mode once: it never changes, and every later decision - # (initial token state, header prefix, whether the hook refreshes) follows - # from it. Keeping the string comparison here means an unexpected auth_type - # can't be read as api_key by one branch and jwt by another. - self._is_api_key = auth_type == "api_key" + # The auth mode is derived from api_key rather than passed in as a mode + # name: it never changes, every later decision follows from it (initial + # token state, header prefix, whether the hook refreshes), and there is no + # spelling of a mode that silently means something other than intended. + self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index 94e220c0..1b0f67ea 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -39,7 +39,7 @@ class ThingsboardClient: """User-facing ThingsBoard client. Wraps the generated per-controller APIs with authentication management and - transparent 429 retry. Supports three authentication modes: + transparent 429 retry. Supports three authentication modes, plus unauthenticated: 1. Username + password (JWT): ThingsboardClient(url, username, password) @@ -92,7 +92,8 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given, - or if password=/refresh_token= is given without its own mode. + or if either half of username=/password= or of token=/refresh_token= + is given without the other. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -125,9 +126,8 @@ def __init__( configuration = Configuration(host=url) - # Determine auth type - auth_type = "api_key" if api_key is not None else "jwt" - auth_manager = _AuthManager(url, auth_type, api_key) + # api_key selects the auth mode: present means API key auth, absent means JWT + auth_manager = _AuthManager(url, api_key) # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook diff --git a/common/_auth.py b/common/_auth.py index 953a4b5f..be8580b3 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -113,20 +113,20 @@ class _AuthManager: lock and skip the refresh once the first thread completes. """ - def __init__(self, base_url: str, auth_type: str, api_key=None): + def __init__(self, base_url: str, api_key=None): """ Args: - base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). - Trailing slashes are stripped. - auth_type: Either 'jwt' (username/password or token) or 'api_key'. - api_key: The API key string when auth_type='api_key', else None. + base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). + Trailing slashes are stripped. + api_key: The API key string for API key auth, or None for JWT auth + (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # Resolve the auth mode once: it never changes, and every later decision - # (initial token state, header prefix, whether the hook refreshes) follows - # from it. Keeping the string comparison here means an unexpected auth_type - # can't be read as api_key by one branch and jwt by another. - self._is_api_key = auth_type == "api_key" + # The auth mode is derived from api_key rather than passed in as a mode + # name: it never changes, every later decision follows from it (initial + # token state, header prefix, whether the hook refreshes), and there is no + # spelling of a mode that silently means something other than intended. + self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False diff --git a/common/client.py b/common/client.py index 94e220c0..1b0f67ea 100644 --- a/common/client.py +++ b/common/client.py @@ -39,7 +39,7 @@ class ThingsboardClient: """User-facing ThingsBoard client. Wraps the generated per-controller APIs with authentication management and - transparent 429 retry. Supports three authentication modes: + transparent 429 retry. Supports three authentication modes, plus unauthenticated: 1. Username + password (JWT): ThingsboardClient(url, username, password) @@ -92,7 +92,8 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given, - or if password=/refresh_token= is given without its own mode. + or if either half of username=/password= or of token=/refresh_token= + is given without the other. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -125,9 +126,8 @@ def __init__( configuration = Configuration(host=url) - # Determine auth type - auth_type = "api_key" if api_key is not None else "jwt" - auth_manager = _AuthManager(url, auth_type, api_key) + # api_key selects the auth mode: present means API key auth, absent means JWT + auth_manager = _AuthManager(url, api_key) # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook diff --git a/common/docs/tb-examples.md b/common/docs/tb-examples.md index 0af4f168..ccd176df 100644 --- a/common/docs/tb-examples.md +++ b/common/docs/tb-examples.md @@ -23,6 +23,35 @@ from tb_ce_client import ThingsboardClient client = ThingsboardClient("http://localhost:9090", api_key="your-api-key") ``` +## Pre-existing Token + +Injects an externally obtained JWT; no login call is made. + +```python +from tb_ce_client import ThingsboardClient + +client = ThingsboardClient( + "http://localhost:9090", + token="eyJhbGciOi...", + refresh_token="eyJhbGciOi...", +) +``` + +## No Authentication + +All auth arguments are optional. Omit them for a client that sends no +`X-Authorization` header, for use with the `/api/noauth` endpoints. + +```python +from tb_ce_client import ThingsboardClient + +client = ThingsboardClient("http://localhost:9090") +``` + +The three authenticated modes above are mutually exclusive — passing more than one +raises `ValueError`, as does passing `username=`, `password=`, `token=` or +`refresh_token=` without its companion argument. + ## Context Manager ```python diff --git a/generate-client.sh b/generate-client.sh index f8ad4596..0b0a554e 100755 --- a/generate-client.sh +++ b/generate-client.sh @@ -61,6 +61,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# tests/test_common_overlay.py parses this array to know which editions to check, +# so keep it on one line at column 0 with double-quoted entries. EDITIONS=("ce" "pe" "paas") VERBOSE=false diff --git a/paas/docs/tb-examples.md b/paas/docs/tb-examples.md index 0af4f168..ccd176df 100644 --- a/paas/docs/tb-examples.md +++ b/paas/docs/tb-examples.md @@ -23,6 +23,35 @@ from tb_ce_client import ThingsboardClient client = ThingsboardClient("http://localhost:9090", api_key="your-api-key") ``` +## Pre-existing Token + +Injects an externally obtained JWT; no login call is made. + +```python +from tb_ce_client import ThingsboardClient + +client = ThingsboardClient( + "http://localhost:9090", + token="eyJhbGciOi...", + refresh_token="eyJhbGciOi...", +) +``` + +## No Authentication + +All auth arguments are optional. Omit them for a client that sends no +`X-Authorization` header, for use with the `/api/noauth` endpoints. + +```python +from tb_ce_client import ThingsboardClient + +client = ThingsboardClient("http://localhost:9090") +``` + +The three authenticated modes above are mutually exclusive — passing more than one +raises `ValueError`, as does passing `username=`, `password=`, `token=` or +`refresh_token=` without its companion argument. + ## Context Manager ```python diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 953a4b5f..be8580b3 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -113,20 +113,20 @@ class _AuthManager: lock and skip the refresh once the first thread completes. """ - def __init__(self, base_url: str, auth_type: str, api_key=None): + def __init__(self, base_url: str, api_key=None): """ Args: - base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). - Trailing slashes are stripped. - auth_type: Either 'jwt' (username/password or token) or 'api_key'. - api_key: The API key string when auth_type='api_key', else None. + base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). + Trailing slashes are stripped. + api_key: The API key string for API key auth, or None for JWT auth + (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # Resolve the auth mode once: it never changes, and every later decision - # (initial token state, header prefix, whether the hook refreshes) follows - # from it. Keeping the string comparison here means an unexpected auth_type - # can't be read as api_key by one branch and jwt by another. - self._is_api_key = auth_type == "api_key" + # The auth mode is derived from api_key rather than passed in as a mode + # name: it never changes, every later decision follows from it (initial + # token state, header prefix, whether the hook refreshes), and there is no + # spelling of a mode that silently means something other than intended. + self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index 94e220c0..1b0f67ea 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -39,7 +39,7 @@ class ThingsboardClient: """User-facing ThingsBoard client. Wraps the generated per-controller APIs with authentication management and - transparent 429 retry. Supports three authentication modes: + transparent 429 retry. Supports three authentication modes, plus unauthenticated: 1. Username + password (JWT): ThingsboardClient(url, username, password) @@ -92,7 +92,8 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given, - or if password=/refresh_token= is given without its own mode. + or if either half of username=/password= or of token=/refresh_token= + is given without the other. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -125,9 +126,8 @@ def __init__( configuration = Configuration(host=url) - # Determine auth type - auth_type = "api_key" if api_key is not None else "jwt" - auth_manager = _AuthManager(url, auth_type, api_key) + # api_key selects the auth mode: present means API key auth, absent means JWT + auth_manager = _AuthManager(url, api_key) # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook diff --git a/pe/docs/tb-examples.md b/pe/docs/tb-examples.md index 0af4f168..ccd176df 100644 --- a/pe/docs/tb-examples.md +++ b/pe/docs/tb-examples.md @@ -23,6 +23,35 @@ from tb_ce_client import ThingsboardClient client = ThingsboardClient("http://localhost:9090", api_key="your-api-key") ``` +## Pre-existing Token + +Injects an externally obtained JWT; no login call is made. + +```python +from tb_ce_client import ThingsboardClient + +client = ThingsboardClient( + "http://localhost:9090", + token="eyJhbGciOi...", + refresh_token="eyJhbGciOi...", +) +``` + +## No Authentication + +All auth arguments are optional. Omit them for a client that sends no +`X-Authorization` header, for use with the `/api/noauth` endpoints. + +```python +from tb_ce_client import ThingsboardClient + +client = ThingsboardClient("http://localhost:9090") +``` + +The three authenticated modes above are mutually exclusive — passing more than one +raises `ValueError`, as does passing `username=`, `password=`, `token=` or +`refresh_token=` without its companion argument. + ## Context Manager ```python diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 953a4b5f..be8580b3 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -113,20 +113,20 @@ class _AuthManager: lock and skip the refresh once the first thread completes. """ - def __init__(self, base_url: str, auth_type: str, api_key=None): + def __init__(self, base_url: str, api_key=None): """ Args: - base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). - Trailing slashes are stripped. - auth_type: Either 'jwt' (username/password or token) or 'api_key'. - api_key: The API key string when auth_type='api_key', else None. + base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). + Trailing slashes are stripped. + api_key: The API key string for API key auth, or None for JWT auth + (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # Resolve the auth mode once: it never changes, and every later decision - # (initial token state, header prefix, whether the hook refreshes) follows - # from it. Keeping the string comparison here means an unexpected auth_type - # can't be read as api_key by one branch and jwt by another. - self._is_api_key = auth_type == "api_key" + # The auth mode is derived from api_key rather than passed in as a mode + # name: it never changes, every later decision follows from it (initial + # token state, header prefix, whether the hook refreshes), and there is no + # spelling of a mode that silently means something other than intended. + self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() self._refreshing = False diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index 94e220c0..1b0f67ea 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -39,7 +39,7 @@ class ThingsboardClient: """User-facing ThingsBoard client. Wraps the generated per-controller APIs with authentication management and - transparent 429 retry. Supports three authentication modes: + transparent 429 retry. Supports three authentication modes, plus unauthenticated: 1. Username + password (JWT): ThingsboardClient(url, username, password) @@ -92,7 +92,8 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given, - or if password=/refresh_token= is given without its own mode. + or if either half of username=/password= or of token=/refresh_token= + is given without the other. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -125,9 +126,8 @@ def __init__( configuration = Configuration(host=url) - # Determine auth type - auth_type = "api_key" if api_key is not None else "jwt" - auth_manager = _AuthManager(url, auth_type, api_key) + # api_key selects the auth mode: present means API key auth, absent means JWT + auth_manager = _AuthManager(url, api_key) # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook diff --git a/tests/test_auth.py b/tests/test_auth.py index 8dd8290c..7a0fefe1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -67,7 +67,7 @@ def test_malformed_base64_returns_minus_one(self): class TestJwtLogin(unittest.TestCase): def test_jwt_login(self): """on_login stores credentials and builds _TokenInfo from provided JWTs.""" - auth = _AuthManager("http://tb:9090", "jwt", None) + auth = _AuthManager("http://tb:9090") token = make_token(exp_offset_s=3600, iat_offset_s=0) refresh = make_refresh_token(exp_offset_s=86400) @@ -91,7 +91,7 @@ def test_jwt_login(self): class TestHookRefreshesExpiredToken(unittest.TestCase): def test_hook_refreshes_expired_token(self): """hook() calls /api/auth/token when access token is expired but refresh is valid.""" - auth = _AuthManager("http://tb:9090", "jwt", None) + auth = _AuthManager("http://tb:9090") # expired access token (exp in the past) old_token = make_token(exp_offset_s=-3600, iat_offset_s=0) # valid refresh token @@ -112,7 +112,7 @@ def test_hook_refreshes_expired_token(self): def test_hook_skips_when_token_valid(self): """hook() does not call HTTP when access token is still valid.""" - auth = _AuthManager("http://tb:9090", "jwt", None) + auth = _AuthManager("http://tb:9090") # token valid for an hour token = make_token(exp_offset_s=3600, iat_offset_s=0) refresh = make_refresh_token(exp_offset_s=86400) @@ -135,7 +135,7 @@ def test_hook_skips_when_token_valid(self): class TestClockSkewCompensation(unittest.TestCase): def test_clock_skew_compensation(self): """clock_diff is computed from iat; a 5s server-ahead skew is absorbed into estimates.""" - auth = _AuthManager("http://tb:9090", "jwt", None) + auth = _AuthManager("http://tb:9090") # iat is 5 seconds ahead of "now" (simulates server clock being 5s ahead) skew_s = 5 token = make_token(exp_offset_s=skew_s + 35, iat_offset_s=skew_s) @@ -164,7 +164,7 @@ def test_clock_skew_compensation(self): class TestReloginOnRefreshExpiry(unittest.TestCase): def test_relogin_on_refresh_expiry(self): """hook() calls /api/auth/login when both access and refresh tokens are expired.""" - auth = _AuthManager("http://tb:9090", "jwt", None) + auth = _AuthManager("http://tb:9090") expired_token = make_token(exp_offset_s=-7200, iat_offset_s=0) expired_refresh = make_refresh_token(exp_offset_s=-3600) auth.on_login("user@tb.io", "password", expired_token, expired_refresh) @@ -182,7 +182,7 @@ def test_relogin_on_refresh_expiry(self): def test_refresh_failure_falls_back_to_relogin(self): """When refresh fails, _do_login is called as fallback (AUTH-04).""" - auth = _AuthManager("http://tb:9090", "jwt", None) + auth = _AuthManager("http://tb:9090") # expired access token, but valid refresh token so _do_refresh_token will be tried first expired_token = make_token(exp_offset_s=-7200, iat_offset_s=0) valid_refresh = make_refresh_token(exp_offset_s=86400) @@ -220,7 +220,7 @@ def side_effect(path, body): class TestApiKeyAuthNoRefresh(unittest.TestCase): def test_api_key_auth_no_refresh(self): """hook() returns immediately for api_key auth without making HTTP calls.""" - auth = _AuthManager("http://tb:9090", "api_key", "test-key-12345") + auth = _AuthManager("http://tb:9090", "test-key-12345") with patch.object(auth, "_raw_post") as mock_post: config = _mock_configuration() @@ -239,7 +239,7 @@ def test_api_key_auth_no_refresh(self): class TestPreexistingToken(unittest.TestCase): def test_preexisting_token(self): """set_external_token parses exp times correctly from provided JWTs.""" - auth = _AuthManager("http://tb:9090", "jwt", None) + auth = _AuthManager("http://tb:9090") now_s = int(time.time()) token = make_jwt({"exp": now_s + 3600, "iat": now_s}) refresh = make_jwt({"exp": now_s + 86400}) diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 8b0c7ac0..774c1b2f 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -20,26 +20,30 @@ _REPO_ROOT = Path(__file__).parent.parent _COMMON_DIR = _REPO_ROOT / "common" -# Entries in common/ that are deliberately NOT byte-identical in the editions: +# Excluded only where they sit at the top level of common/ — a nested file of the +# same name would still be overlaid verbatim and must stay checked: # docs — generate-client.sh overlays it into /docs, not the package # __init__.py — post_process.py merges it with the generated package __init__ -# __pycache__ — build output, never committed _EXCLUDED_TOP_LEVEL = {"docs", "__init__.py"} -_EXCLUDED_DIRS = {"__pycache__"} +# Excluded at any depth, because they are build output that is never committed: +_EXCLUDED_DIRS_ANY_DEPTH = {"__pycache__"} -def _overlaid_filenames() -> list[str]: - """Paths under common/ that must appear verbatim in every edition package. - Walks recursively and returns paths relative to common/, because +def _overlaid_filenames(root: Path = _COMMON_DIR) -> list[str]: + """Paths under root that must appear verbatim in every edition package. + + Walks recursively and returns paths relative to root, because generate-client.sh `cp -r`s every entry — subdirectories included. """ names = [] - for path in _COMMON_DIR.rglob("*"): + for path in root.rglob("*"): if not path.is_file(): continue - rel = path.relative_to(_COMMON_DIR) - if rel.parts[0] in _EXCLUDED_TOP_LEVEL or _EXCLUDED_DIRS.intersection(rel.parts): + rel = path.relative_to(root) + if rel.parts[0] in _EXCLUDED_TOP_LEVEL: + continue + if _EXCLUDED_DIRS_ANY_DEPTH.intersection(rel.parts): continue names.append(str(rel)) return sorted(names) @@ -63,6 +67,30 @@ def test_discovery_finds_filenames_and_editions(): assert _editions(), "no editions parsed from generate-client.sh" +def test_walk_exclusion_semantics(tmp_path): + """The two exclusion sets are anchored differently — pin that against a fixture. + + common/ is flat today apart from docs/, so nothing real exercises the recursion + or the any-depth filter; this checks them now rather than the first time someone + adds a subdirectory. + """ + (tmp_path / "client.py").write_text("x") + (tmp_path / "__init__.py").write_text("x") # excluded: top level + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "guide.md").write_text("x") # excluded: under top-level docs + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "mod.py").write_text("x") # kept: nested file + (tmp_path / "sub" / "__init__.py").write_text("x") # kept: not at the top level + (tmp_path / "sub" / "__pycache__").mkdir() + (tmp_path / "sub" / "__pycache__" / "mod.pyc").write_bytes(b"x") # excluded: any depth + + assert _overlaid_filenames(tmp_path) == [ + "client.py", + str(Path("sub") / "__init__.py"), + str(Path("sub") / "mod.py"), + ] + + @pytest.mark.parametrize("edition", _editions()) @pytest.mark.parametrize("filename", _overlaid_filenames()) def test_edition_copy_matches_common(edition, filename): From 4dda84f15a98a54f7d0084d777c7f135a6dead05 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 14:24:57 +0300 Subject: [PATCH 06/17] Address fifth review: correct token= pairing docs, guard docs overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token= on its own is valid — only refresh_token= without token= raises — but the Raises: block and tb-examples.md both claimed the pair was required in both directions. Corrected both, and pinned the behaviour with a test for ThingsboardClient(url, token=...) with no refresh token. Added test_edition_doc_copy_matches_common: the package overlay check skips common/docs because generate-client.sh copies it to /docs instead, so nothing compared those trees and a hand-edit to one edition's copy of the shared documentation would have passed CI. Also added the two new sections to test_readme.py's required list. Trimmed the _AuthManager comment to the durable statement and dropped the duplicate at the call site; _overlaid_filenames() now returns as_posix() paths so ids and failure messages read as repo paths on every platform, and the walk fixture covers a nested docs/ file as well as a nested __init__.py. --- ce/docs/tb-examples.md | 5 +-- ce/tb_ce_client/_auth.py | 6 ++-- ce/tb_ce_client/client.py | 8 ++--- common/_auth.py | 6 ++-- common/client.py | 8 ++--- common/docs/tb-examples.md | 5 +-- paas/docs/tb-examples.md | 5 +-- paas/tb_paas_client/_auth.py | 6 ++-- paas/tb_paas_client/client.py | 8 ++--- pe/docs/tb-examples.md | 5 +-- pe/tb_pe_client/_auth.py | 6 ++-- pe/tb_pe_client/client.py | 8 ++--- tests/test_client.py | 14 +++++++++ tests/test_common_overlay.py | 57 +++++++++++++++++++++++++++++------ tests/test_readme.py | 6 ++++ 15 files changed, 103 insertions(+), 50 deletions(-) diff --git a/ce/docs/tb-examples.md b/ce/docs/tb-examples.md index ccd176df..cfbbcb2a 100644 --- a/ce/docs/tb-examples.md +++ b/ce/docs/tb-examples.md @@ -49,8 +49,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes above are mutually exclusive — passing more than one -raises `ValueError`, as does passing `username=`, `password=`, `token=` or -`refresh_token=` without its companion argument. +raises `ValueError`, as does passing `username=`, `password=` or `refresh_token=` +without its companion argument. `token=` on its own is valid; it simply means no +refresh is possible. ## Context Manager diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index be8580b3..0734d1eb 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -122,10 +122,8 @@ def __init__(self, base_url: str, api_key=None): (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # The auth mode is derived from api_key rather than passed in as a mode - # name: it never changes, every later decision follows from it (initial - # token state, header prefix, whether the hook refreshes), and there is no - # spelling of a mode that silently means something other than intended. + # Mode is fixed at construction: api_key present -> API key auth, absent -> JWT. + # Header prefix, initial token state and hook behaviour all follow from it. self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index 1b0f67ea..f9c47129 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -91,9 +91,10 @@ def __init__( _RetryingRESTClient. If False, uses plain RESTClientObject. Raises: - ValueError: If more than one of username=, api_key= or token= is given, - or if either half of username=/password= or of token=/refresh_token= - is given without the other. + ValueError: If more than one of username=, api_key= or token= is given; + if password= is given without username= or vice versa; or if + refresh_token= is given without token=. token= on its own is valid — + it simply means no refresh is possible. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -126,7 +127,6 @@ def __init__( configuration = Configuration(host=url) - # api_key selects the auth mode: present means API key auth, absent means JWT auth_manager = _AuthManager(url, api_key) # Install the refresh hook so the hook fires before every API request diff --git a/common/_auth.py b/common/_auth.py index be8580b3..0734d1eb 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -122,10 +122,8 @@ def __init__(self, base_url: str, api_key=None): (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # The auth mode is derived from api_key rather than passed in as a mode - # name: it never changes, every later decision follows from it (initial - # token state, header prefix, whether the hook refreshes), and there is no - # spelling of a mode that silently means something other than intended. + # Mode is fixed at construction: api_key present -> API key auth, absent -> JWT. + # Header prefix, initial token state and hook behaviour all follow from it. self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() diff --git a/common/client.py b/common/client.py index 1b0f67ea..f9c47129 100644 --- a/common/client.py +++ b/common/client.py @@ -91,9 +91,10 @@ def __init__( _RetryingRESTClient. If False, uses plain RESTClientObject. Raises: - ValueError: If more than one of username=, api_key= or token= is given, - or if either half of username=/password= or of token=/refresh_token= - is given without the other. + ValueError: If more than one of username=, api_key= or token= is given; + if password= is given without username= or vice versa; or if + refresh_token= is given without token=. token= on its own is valid — + it simply means no refresh is possible. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -126,7 +127,6 @@ def __init__( configuration = Configuration(host=url) - # api_key selects the auth mode: present means API key auth, absent means JWT auth_manager = _AuthManager(url, api_key) # Install the refresh hook so the hook fires before every API request diff --git a/common/docs/tb-examples.md b/common/docs/tb-examples.md index ccd176df..cfbbcb2a 100644 --- a/common/docs/tb-examples.md +++ b/common/docs/tb-examples.md @@ -49,8 +49,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes above are mutually exclusive — passing more than one -raises `ValueError`, as does passing `username=`, `password=`, `token=` or -`refresh_token=` without its companion argument. +raises `ValueError`, as does passing `username=`, `password=` or `refresh_token=` +without its companion argument. `token=` on its own is valid; it simply means no +refresh is possible. ## Context Manager diff --git a/paas/docs/tb-examples.md b/paas/docs/tb-examples.md index ccd176df..cfbbcb2a 100644 --- a/paas/docs/tb-examples.md +++ b/paas/docs/tb-examples.md @@ -49,8 +49,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes above are mutually exclusive — passing more than one -raises `ValueError`, as does passing `username=`, `password=`, `token=` or -`refresh_token=` without its companion argument. +raises `ValueError`, as does passing `username=`, `password=` or `refresh_token=` +without its companion argument. `token=` on its own is valid; it simply means no +refresh is possible. ## Context Manager diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index be8580b3..0734d1eb 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -122,10 +122,8 @@ def __init__(self, base_url: str, api_key=None): (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # The auth mode is derived from api_key rather than passed in as a mode - # name: it never changes, every later decision follows from it (initial - # token state, header prefix, whether the hook refreshes), and there is no - # spelling of a mode that silently means something other than intended. + # Mode is fixed at construction: api_key present -> API key auth, absent -> JWT. + # Header prefix, initial token state and hook behaviour all follow from it. self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index 1b0f67ea..f9c47129 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -91,9 +91,10 @@ def __init__( _RetryingRESTClient. If False, uses plain RESTClientObject. Raises: - ValueError: If more than one of username=, api_key= or token= is given, - or if either half of username=/password= or of token=/refresh_token= - is given without the other. + ValueError: If more than one of username=, api_key= or token= is given; + if password= is given without username= or vice versa; or if + refresh_token= is given without token=. token= on its own is valid — + it simply means no refresh is possible. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -126,7 +127,6 @@ def __init__( configuration = Configuration(host=url) - # api_key selects the auth mode: present means API key auth, absent means JWT auth_manager = _AuthManager(url, api_key) # Install the refresh hook so the hook fires before every API request diff --git a/pe/docs/tb-examples.md b/pe/docs/tb-examples.md index ccd176df..cfbbcb2a 100644 --- a/pe/docs/tb-examples.md +++ b/pe/docs/tb-examples.md @@ -49,8 +49,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes above are mutually exclusive — passing more than one -raises `ValueError`, as does passing `username=`, `password=`, `token=` or -`refresh_token=` without its companion argument. +raises `ValueError`, as does passing `username=`, `password=` or `refresh_token=` +without its companion argument. `token=` on its own is valid; it simply means no +refresh is possible. ## Context Manager diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index be8580b3..0734d1eb 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -122,10 +122,8 @@ def __init__(self, base_url: str, api_key=None): (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # The auth mode is derived from api_key rather than passed in as a mode - # name: it never changes, every later decision follows from it (initial - # token state, header prefix, whether the hook refreshes), and there is no - # spelling of a mode that silently means something other than intended. + # Mode is fixed at construction: api_key present -> API key auth, absent -> JWT. + # Header prefix, initial token state and hook behaviour all follow from it. self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index 1b0f67ea..f9c47129 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -91,9 +91,10 @@ def __init__( _RetryingRESTClient. If False, uses plain RESTClientObject. Raises: - ValueError: If more than one of username=, api_key= or token= is given, - or if either half of username=/password= or of token=/refresh_token= - is given without the other. + ValueError: If more than one of username=, api_key= or token= is given; + if password= is given without username= or vice versa; or if + refresh_token= is given without token=. token= on its own is valid — + it simply means no refresh is possible. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). @@ -126,7 +127,6 @@ def __init__( configuration = Configuration(host=url) - # api_key selects the auth mode: present means API key auth, absent means JWT auth_manager = _AuthManager(url, api_key) # Install the refresh hook so the hook fires before every API request diff --git a/tests/test_client.py b/tests/test_client.py index b7c5756b..1656d605 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -94,6 +94,20 @@ def test_preexisting_token(self): self.assertEqual(cfg.api_key.get("ApiKeyForm"), "jwt.payload.sig") self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") + def test_preexisting_token_without_refresh_token(self): + """token= alone is valid — it just means no refresh is possible. + + The mutual-exclusion and companion checks deliberately do not pair token= with + refresh_token=, so this pins the asymmetry the docstring describes. + """ + with patch(_LOGIN_PATCH_TARGET) as mock_login: + client = ThingsboardClient(URL, token="jwt.payload.sig") + mock_login.assert_not_called() + cfg = client.api_client.configuration + self.assertEqual(cfg.api_key.get("ApiKeyForm"), "jwt.payload.sig") + self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") + self.assertFalse(client.get_refresh_token()) + def test_no_auth_leaves_header_slot_absent(self): """A client built without auth kwargs creates no ApiKeyForm slot. diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 774c1b2f..36f31d3a 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -1,11 +1,12 @@ """ -Guards the common/ -> edition overlay performed by generate-client.sh. +Guards the common/ -> edition overlays performed by generate-client.sh. -generate-client.sh copies common/ verbatim into every tb__client/ package, -and those copies are committed. Nothing else in CI compares them, so a fix landed in -common/ but overlaid into only some editions would ship stale code to the rest. +generate-client.sh copies common/ verbatim into every tb__client/ package and +common/docs/ into every /docs/, and those copies are committed. Nothing else in +CI compares them, so a fix landed in common/ but overlaid into only some editions would +ship stale code — or stale documentation — to the rest. -Both lists are taken from the things that define them rather than hardcoded here: +Every list is taken from the thing that defines it rather than hardcoded here: filenames from common/ itself, editions from generate-client.sh. Adding either a file or an edition extends the check with no test edit — and, because the editions come from the script rather than from whichever directories happen to exist, an edition @@ -33,7 +34,7 @@ def _overlaid_filenames(root: Path = _COMMON_DIR) -> list[str]: """Paths under root that must appear verbatim in every edition package. - Walks recursively and returns paths relative to root, because + Walks recursively and returns forward-slash paths relative to root, because generate-client.sh `cp -r`s every entry — subdirectories included. """ names = [] @@ -45,10 +46,22 @@ def _overlaid_filenames(root: Path = _COMMON_DIR) -> list[str]: continue if _EXCLUDED_DIRS_ANY_DEPTH.intersection(rel.parts): continue - names.append(str(rel)) + names.append(rel.as_posix()) return sorted(names) +def _overlaid_doc_filenames() -> list[str]: + """Names in common/docs/ that must appear verbatim in every /docs/. + + Flat rather than recursive: the script overlays these with `cp common/docs/* ...`, + which copies top-level entries only. + """ + docs_dir = _COMMON_DIR / "docs" + if not docs_dir.is_dir(): + return [] + return sorted(p.name for p in docs_dir.iterdir() if p.is_file()) + + def _editions() -> list[str]: """Edition names parsed from the EDITIONS array in generate-client.sh.""" script = (_REPO_ROOT / "generate-client.sh").read_text(encoding="utf-8") @@ -58,12 +71,13 @@ def _editions() -> list[str]: def test_discovery_finds_filenames_and_editions(): - """Both derived lists are non-empty. + """Every derived list is non-empty. Without this, a glob or regex that silently matched nothing would collect zero parametrized cases and the sync check would vacuously pass. """ assert _overlaid_filenames(), "no overlaid files discovered in common/" + assert _overlaid_doc_filenames(), "no overlaid docs discovered in common/docs/" assert _editions(), "no editions parsed from generate-client.sh" @@ -81,13 +95,16 @@ def test_walk_exclusion_semantics(tmp_path): (tmp_path / "sub").mkdir() (tmp_path / "sub" / "mod.py").write_text("x") # kept: nested file (tmp_path / "sub" / "__init__.py").write_text("x") # kept: not at the top level + (tmp_path / "sub" / "docs").mkdir() + (tmp_path / "sub" / "docs" / "guide.md").write_text("x") # kept: not at the top level (tmp_path / "sub" / "__pycache__").mkdir() (tmp_path / "sub" / "__pycache__" / "mod.pyc").write_bytes(b"x") # excluded: any depth assert _overlaid_filenames(tmp_path) == [ "client.py", - str(Path("sub") / "__init__.py"), - str(Path("sub") / "mod.py"), + "sub/__init__.py", + "sub/docs/guide.md", + "sub/mod.py", ] @@ -104,3 +121,23 @@ def test_edition_copy_matches_common(edition, filename): f"Edit common/{filename} and re-run generate-client.sh (or copy it into " f"every tb_*_client/ package)." ) + + +@pytest.mark.parametrize("edition", _editions()) +@pytest.mark.parametrize("filename", _overlaid_doc_filenames()) +def test_edition_doc_copy_matches_common(edition, filename): + """Each committed /docs/ copy is byte-identical to its common/docs/ source. + + The package overlay above skips common/docs because the script sends it to + /docs instead; without this, a hand-edit to one edition's copy of the + shared documentation would pass CI unnoticed. + """ + source = _COMMON_DIR / "docs" / filename + copy = _REPO_ROOT / edition / "docs" / filename + + assert copy.is_file(), f"{copy} is missing — run generate-client.sh" + assert copy.read_bytes() == source.read_bytes(), ( + f"{copy.relative_to(_REPO_ROOT)} is out of sync with common/docs/{filename}. " + f"Edit common/docs/{filename} and re-run generate-client.sh (or copy it into " + f"every /docs/ directory)." + ) diff --git a/tests/test_readme.py b/tests/test_readme.py index 8cec78de..66e0b6a8 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -130,6 +130,12 @@ def test_tb_examples_required_sections(): assert "with " in lower or "context manager" in lower, ( "tb-examples.md missing with-statement section (must contain 'with ' or 'context manager')" ) + assert "pre-existing token" in lower, ( + "tb-examples.md missing pre-existing token section (must contain 'pre-existing token')" + ) + assert "no authentication" in lower, ( + "tb-examples.md missing no-authentication section (must contain 'no authentication')" + ) def test_tb_examples_code_blocks_valid_python(): From 6eec0f2f9d3a63dce469835f01908fb038b9d2d4 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 14:47:35 +0300 Subject: [PATCH 07/17] Address sixth review: fail loudly on missing docs, honour None contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _overlaid_doc_filenames() returned [] when common/docs/ was absent, which would have collected zero parametrized cases and passed vacuously — the exact failure this module exists to prevent. It now asserts, takes a root parameter like its sibling, and has a fixture test pinning the documented flat-not-recursive walk. get_refresh_token() documented "or None if not available" in both client.py and _auth.py, but set_external_token stored `refresh_token or ""`, so token= without refresh_token= returned the empty string. Dropped the `or ""` — _build_token_info already treats a falsy refresh token as no-expiry — so the accessor now matches its contract. Note this changes that one path's return value from "" to None. The test asserts assertIsNone instead of a loose assertFalse, which is what let the mismatch go unnoticed. Docs: moved the "token= alone is valid" note out of Raises: (which documents exceptions) into the refresh_token= arg and the mode-3 example; README.md and tb-examples.md now use the docstring's explicit pairing wording and both mention the pairing that is not enforced. Tests: _DOCS_DIRNAME replaces three coupled "docs" literals; _assert_identical collapses the two near-identical overlay bodies and renders both paths with as_posix() so messages read the same on every platform; the tb-examples section checks became a table matching the two auth-mode sections on their headings; and the duplicated header assertions in the two token= tests moved into a helper. --- README.md | 5 +-- ce/docs/tb-examples.md | 6 ++-- ce/tb_ce_client/_auth.py | 12 ++++--- ce/tb_ce_client/client.py | 7 ++-- common/_auth.py | 12 ++++--- common/client.py | 7 ++-- common/docs/tb-examples.md | 6 ++-- paas/docs/tb-examples.md | 6 ++-- paas/tb_paas_client/_auth.py | 12 ++++--- paas/tb_paas_client/client.py | 7 ++-- pe/docs/tb-examples.md | 6 ++-- pe/tb_pe_client/_auth.py | 12 ++++--- pe/tb_pe_client/client.py | 7 ++-- tests/test_client.py | 24 +++++++------ tests/test_common_overlay.py | 67 +++++++++++++++++++++++------------ tests/test_readme.py | 34 +++++++++--------- 16 files changed, 139 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index e36abb3f..bc30f22e 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes are mutually exclusive — passing more than one raises -`ValueError`, as does passing `password=`, `refresh_token=` or `username=` without its -companion argument. +`ValueError`, as does passing `username=` without `password=` or vice versa, or +`refresh_token=` without `token=`. `token=` on its own is valid; it simply means the +token is never refreshed. ## Resource cleanup diff --git a/ce/docs/tb-examples.md b/ce/docs/tb-examples.md index cfbbcb2a..cec1a7ed 100644 --- a/ce/docs/tb-examples.md +++ b/ce/docs/tb-examples.md @@ -49,9 +49,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes above are mutually exclusive — passing more than one -raises `ValueError`, as does passing `username=`, `password=` or `refresh_token=` -without its companion argument. `token=` on its own is valid; it simply means no -refresh is possible. +raises `ValueError`, as does passing `username=` without `password=` or vice versa, or +`refresh_token=` without `token=`. `token=` on its own is valid; it simply means the +token is never refreshed. ## Context Manager diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 0734d1eb..7fef50b4 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -111,6 +111,8 @@ class _AuthManager: Thread safety: a threading.Lock protects the _refreshing flag so that only one concurrent API thread triggers a refresh call. Other threads wait at the lock and skip the refresh once the first thread completes. + + The auth mode is decided once in __init__ and never re-derived per request. """ def __init__(self, base_url: str, api_key=None): @@ -122,8 +124,6 @@ def __init__(self, base_url: str, api_key=None): (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # Mode is fixed at construction: api_key present -> API key auth, absent -> JWT. - # Header prefix, initial token state and hook behaviour all follow from it. self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() @@ -147,8 +147,12 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str) self._token_info = self._build_token_info(token, refresh_token) def set_external_token(self, token: str, refresh_token=None) -> None: - """Set a pre-existing token without storing login credentials.""" - self._token_info = self._build_token_info(token, refresh_token or "") + """Set a pre-existing token without storing login credentials. + + refresh_token is passed through as-is so that omitting it leaves + get_refresh_token() returning None, as its docstring promises. + """ + self._token_info = self._build_token_info(token, refresh_token) def get_token(self): """Return the current access token, or None if not yet set.""" diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index f9c47129..cf2f4ea8 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -52,6 +52,7 @@ class ThingsboardClient: 3. Pre-existing token: ThingsboardClient(url, token="jwt", refresh_token="jwt") Injects an externally obtained JWT; no login call made. + refresh_token is optional — omit it for a token that is never refreshed. The three modes are mutually exclusive — passing more than one raises ValueError. All auth arguments are optional: omitting them yields an unauthenticated client @@ -83,7 +84,8 @@ def __init__( password: Password for JWT authentication. api_key: API key for X-Authorization: ApiKey authentication. token: Pre-existing JWT access token. - refresh_token: Pre-existing JWT refresh token (used with token=). + refresh_token: Pre-existing JWT refresh token (used with token=). Omit it + to install a token that is never refreshed. max_retries: Maximum retry attempts on HTTP 429 (default 3). initial_retry_delay_ms: Base backoff delay in milliseconds (default 1000). max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). @@ -93,8 +95,7 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; if password= is given without username= or vice versa; or if - refresh_token= is given without token=. token= on its own is valid — - it simply means no refresh is possible. + refresh_token= is given without token=. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). diff --git a/common/_auth.py b/common/_auth.py index 0734d1eb..7fef50b4 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -111,6 +111,8 @@ class _AuthManager: Thread safety: a threading.Lock protects the _refreshing flag so that only one concurrent API thread triggers a refresh call. Other threads wait at the lock and skip the refresh once the first thread completes. + + The auth mode is decided once in __init__ and never re-derived per request. """ def __init__(self, base_url: str, api_key=None): @@ -122,8 +124,6 @@ def __init__(self, base_url: str, api_key=None): (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # Mode is fixed at construction: api_key present -> API key auth, absent -> JWT. - # Header prefix, initial token state and hook behaviour all follow from it. self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() @@ -147,8 +147,12 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str) self._token_info = self._build_token_info(token, refresh_token) def set_external_token(self, token: str, refresh_token=None) -> None: - """Set a pre-existing token without storing login credentials.""" - self._token_info = self._build_token_info(token, refresh_token or "") + """Set a pre-existing token without storing login credentials. + + refresh_token is passed through as-is so that omitting it leaves + get_refresh_token() returning None, as its docstring promises. + """ + self._token_info = self._build_token_info(token, refresh_token) def get_token(self): """Return the current access token, or None if not yet set.""" diff --git a/common/client.py b/common/client.py index f9c47129..cf2f4ea8 100644 --- a/common/client.py +++ b/common/client.py @@ -52,6 +52,7 @@ class ThingsboardClient: 3. Pre-existing token: ThingsboardClient(url, token="jwt", refresh_token="jwt") Injects an externally obtained JWT; no login call made. + refresh_token is optional — omit it for a token that is never refreshed. The three modes are mutually exclusive — passing more than one raises ValueError. All auth arguments are optional: omitting them yields an unauthenticated client @@ -83,7 +84,8 @@ def __init__( password: Password for JWT authentication. api_key: API key for X-Authorization: ApiKey authentication. token: Pre-existing JWT access token. - refresh_token: Pre-existing JWT refresh token (used with token=). + refresh_token: Pre-existing JWT refresh token (used with token=). Omit it + to install a token that is never refreshed. max_retries: Maximum retry attempts on HTTP 429 (default 3). initial_retry_delay_ms: Base backoff delay in milliseconds (default 1000). max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). @@ -93,8 +95,7 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; if password= is given without username= or vice versa; or if - refresh_token= is given without token=. token= on its own is valid — - it simply means no refresh is possible. + refresh_token= is given without token=. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). diff --git a/common/docs/tb-examples.md b/common/docs/tb-examples.md index cfbbcb2a..cec1a7ed 100644 --- a/common/docs/tb-examples.md +++ b/common/docs/tb-examples.md @@ -49,9 +49,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes above are mutually exclusive — passing more than one -raises `ValueError`, as does passing `username=`, `password=` or `refresh_token=` -without its companion argument. `token=` on its own is valid; it simply means no -refresh is possible. +raises `ValueError`, as does passing `username=` without `password=` or vice versa, or +`refresh_token=` without `token=`. `token=` on its own is valid; it simply means the +token is never refreshed. ## Context Manager diff --git a/paas/docs/tb-examples.md b/paas/docs/tb-examples.md index cfbbcb2a..cec1a7ed 100644 --- a/paas/docs/tb-examples.md +++ b/paas/docs/tb-examples.md @@ -49,9 +49,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes above are mutually exclusive — passing more than one -raises `ValueError`, as does passing `username=`, `password=` or `refresh_token=` -without its companion argument. `token=` on its own is valid; it simply means no -refresh is possible. +raises `ValueError`, as does passing `username=` without `password=` or vice versa, or +`refresh_token=` without `token=`. `token=` on its own is valid; it simply means the +token is never refreshed. ## Context Manager diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 0734d1eb..7fef50b4 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -111,6 +111,8 @@ class _AuthManager: Thread safety: a threading.Lock protects the _refreshing flag so that only one concurrent API thread triggers a refresh call. Other threads wait at the lock and skip the refresh once the first thread completes. + + The auth mode is decided once in __init__ and never re-derived per request. """ def __init__(self, base_url: str, api_key=None): @@ -122,8 +124,6 @@ def __init__(self, base_url: str, api_key=None): (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # Mode is fixed at construction: api_key present -> API key auth, absent -> JWT. - # Header prefix, initial token state and hook behaviour all follow from it. self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() @@ -147,8 +147,12 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str) self._token_info = self._build_token_info(token, refresh_token) def set_external_token(self, token: str, refresh_token=None) -> None: - """Set a pre-existing token without storing login credentials.""" - self._token_info = self._build_token_info(token, refresh_token or "") + """Set a pre-existing token without storing login credentials. + + refresh_token is passed through as-is so that omitting it leaves + get_refresh_token() returning None, as its docstring promises. + """ + self._token_info = self._build_token_info(token, refresh_token) def get_token(self): """Return the current access token, or None if not yet set.""" diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index f9c47129..cf2f4ea8 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -52,6 +52,7 @@ class ThingsboardClient: 3. Pre-existing token: ThingsboardClient(url, token="jwt", refresh_token="jwt") Injects an externally obtained JWT; no login call made. + refresh_token is optional — omit it for a token that is never refreshed. The three modes are mutually exclusive — passing more than one raises ValueError. All auth arguments are optional: omitting them yields an unauthenticated client @@ -83,7 +84,8 @@ def __init__( password: Password for JWT authentication. api_key: API key for X-Authorization: ApiKey authentication. token: Pre-existing JWT access token. - refresh_token: Pre-existing JWT refresh token (used with token=). + refresh_token: Pre-existing JWT refresh token (used with token=). Omit it + to install a token that is never refreshed. max_retries: Maximum retry attempts on HTTP 429 (default 3). initial_retry_delay_ms: Base backoff delay in milliseconds (default 1000). max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). @@ -93,8 +95,7 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; if password= is given without username= or vice versa; or if - refresh_token= is given without token=. token= on its own is valid — - it simply means no refresh is possible. + refresh_token= is given without token=. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). diff --git a/pe/docs/tb-examples.md b/pe/docs/tb-examples.md index cfbbcb2a..cec1a7ed 100644 --- a/pe/docs/tb-examples.md +++ b/pe/docs/tb-examples.md @@ -49,9 +49,9 @@ client = ThingsboardClient("http://localhost:9090") ``` The three authenticated modes above are mutually exclusive — passing more than one -raises `ValueError`, as does passing `username=`, `password=` or `refresh_token=` -without its companion argument. `token=` on its own is valid; it simply means no -refresh is possible. +raises `ValueError`, as does passing `username=` without `password=` or vice versa, or +`refresh_token=` without `token=`. `token=` on its own is valid; it simply means the +token is never refreshed. ## Context Manager diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 0734d1eb..7fef50b4 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -111,6 +111,8 @@ class _AuthManager: Thread safety: a threading.Lock protects the _refreshing flag so that only one concurrent API thread triggers a refresh call. Other threads wait at the lock and skip the refresh once the first thread completes. + + The auth mode is decided once in __init__ and never re-derived per request. """ def __init__(self, base_url: str, api_key=None): @@ -122,8 +124,6 @@ def __init__(self, base_url: str, api_key=None): (username/password or an externally supplied token). """ self._base_url = base_url.rstrip("/") - # Mode is fixed at construction: api_key present -> API key auth, absent -> JWT. - # Header prefix, initial token state and hook behaviour all follow from it. self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX self._lock = threading.Lock() @@ -147,8 +147,12 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str) self._token_info = self._build_token_info(token, refresh_token) def set_external_token(self, token: str, refresh_token=None) -> None: - """Set a pre-existing token without storing login credentials.""" - self._token_info = self._build_token_info(token, refresh_token or "") + """Set a pre-existing token without storing login credentials. + + refresh_token is passed through as-is so that omitting it leaves + get_refresh_token() returning None, as its docstring promises. + """ + self._token_info = self._build_token_info(token, refresh_token) def get_token(self): """Return the current access token, or None if not yet set.""" diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index f9c47129..cf2f4ea8 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -52,6 +52,7 @@ class ThingsboardClient: 3. Pre-existing token: ThingsboardClient(url, token="jwt", refresh_token="jwt") Injects an externally obtained JWT; no login call made. + refresh_token is optional — omit it for a token that is never refreshed. The three modes are mutually exclusive — passing more than one raises ValueError. All auth arguments are optional: omitting them yields an unauthenticated client @@ -83,7 +84,8 @@ def __init__( password: Password for JWT authentication. api_key: API key for X-Authorization: ApiKey authentication. token: Pre-existing JWT access token. - refresh_token: Pre-existing JWT refresh token (used with token=). + refresh_token: Pre-existing JWT refresh token (used with token=). Omit it + to install a token that is never refreshed. max_retries: Maximum retry attempts on HTTP 429 (default 3). initial_retry_delay_ms: Base backoff delay in milliseconds (default 1000). max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). @@ -93,8 +95,7 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; if password= is given without username= or vice versa; or if - refresh_token= is given without token=. token= on its own is valid — - it simply means no refresh is possible. + refresh_token= is given without token=. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). diff --git a/tests/test_client.py b/tests/test_client.py index 1656d605..c8ca0528 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -83,30 +83,34 @@ def test_api_key_auth(self): self.assertEqual(cfg.api_key.get("ApiKeyForm"), "test-key") self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "ApiKey") + def _assert_bearer_token_installed(self, client, token): + """The Bearer header slot is seeded from an externally supplied token.""" + cfg = client.api_client.configuration + self.assertEqual(cfg.api_key.get("ApiKeyForm"), token) + self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") + def test_preexisting_token(self): """WRAP-01, AUTH-06: pre-existing token sets header without login().""" with patch(_LOGIN_PATCH_TARGET) as mock_login: client = ThingsboardClient( - URL, token="jwt.payload.sig", refresh_token="jwt.payload.sig" + URL, token="jwt.payload.sig", refresh_token="jwt.refresh.sig" ) mock_login.assert_not_called() - cfg = client.api_client.configuration - self.assertEqual(cfg.api_key.get("ApiKeyForm"), "jwt.payload.sig") - self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") + self._assert_bearer_token_installed(client, "jwt.payload.sig") + self.assertEqual(client.get_refresh_token(), "jwt.refresh.sig") def test_preexisting_token_without_refresh_token(self): - """token= alone is valid — it just means no refresh is possible. + """token= alone is valid — the token is simply never refreshed. The mutual-exclusion and companion checks deliberately do not pair token= with - refresh_token=, so this pins the asymmetry the docstring describes. + refresh_token=, so this pins the asymmetry the docstring describes. The refresh + token is None rather than "", matching get_refresh_token()'s documented contract. """ with patch(_LOGIN_PATCH_TARGET) as mock_login: client = ThingsboardClient(URL, token="jwt.payload.sig") mock_login.assert_not_called() - cfg = client.api_client.configuration - self.assertEqual(cfg.api_key.get("ApiKeyForm"), "jwt.payload.sig") - self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") - self.assertFalse(client.get_refresh_token()) + self._assert_bearer_token_installed(client, "jwt.payload.sig") + self.assertIsNone(client.get_refresh_token()) def test_no_auth_leaves_header_slot_absent(self): """A client built without auth kwargs creates no ApiKeyForm slot. diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 36f31d3a..2e999f2b 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -21,11 +21,16 @@ _REPO_ROOT = Path(__file__).parent.parent _COMMON_DIR = _REPO_ROOT / "common" +# The directory generate-client.sh overlays into /docs rather than into the +# package. Named once because three things depend on it agreeing: the package check +# excludes it, and the docs check uses it as both source and destination. +_DOCS_DIRNAME = "docs" + # Excluded only where they sit at the top level of common/ — a nested file of the # same name would still be overlaid verbatim and must stay checked: # docs — generate-client.sh overlays it into /docs, not the package # __init__.py — post_process.py merges it with the generated package __init__ -_EXCLUDED_TOP_LEVEL = {"docs", "__init__.py"} +_EXCLUDED_TOP_LEVEL = {_DOCS_DIRNAME, "__init__.py"} # Excluded at any depth, because they are build output that is never committed: _EXCLUDED_DIRS_ANY_DEPTH = {"__pycache__"} @@ -50,16 +55,16 @@ def _overlaid_filenames(root: Path = _COMMON_DIR) -> list[str]: return sorted(names) -def _overlaid_doc_filenames() -> list[str]: - """Names in common/docs/ that must appear verbatim in every /docs/. +def _overlaid_doc_filenames(root: Path = _COMMON_DIR / _DOCS_DIRNAME) -> list[str]: + """Names directly under root that must appear verbatim in every /docs/. Flat rather than recursive: the script overlays these with `cp common/docs/* ...`, - which copies top-level entries only. + which copies top-level entries only — and would abort on a subdirectory, since it + passes no -r. Missing the directory entirely is an error rather than an empty list, + so the check can't shrink to zero cases and pass vacuously. """ - docs_dir = _COMMON_DIR / "docs" - if not docs_dir.is_dir(): - return [] - return sorted(p.name for p in docs_dir.iterdir() if p.is_file()) + assert root.is_dir(), f"{root} is missing — run generate-client.sh" + return sorted(p.name for p in root.iterdir() if p.is_file()) def _editions() -> list[str]: @@ -81,6 +86,15 @@ def test_discovery_finds_filenames_and_editions(): assert _editions(), "no editions parsed from generate-client.sh" +def _assert_identical(source: Path, copy: Path, remediation: str) -> None: + """Assert copy exists and is byte-identical to source, or explain how to fix it.""" + assert copy.is_file(), f"{copy} is missing — run generate-client.sh" + assert copy.read_bytes() == source.read_bytes(), ( + f"{copy.relative_to(_REPO_ROOT).as_posix()} is out of sync with " + f"{source.relative_to(_REPO_ROOT).as_posix()}. {remediation}" + ) + + def test_walk_exclusion_semantics(tmp_path): """The two exclusion sets are anchored differently — pin that against a fixture. @@ -108,18 +122,28 @@ def test_walk_exclusion_semantics(tmp_path): ] +def test_doc_walk_is_flat(tmp_path): + """The docs helper takes top-level files only, matching `cp common/docs/*`. + + Kept deliberately flat because the script passes no -r; a subdirectory would abort + it, so silently skipping one here is the right behaviour rather than an oversight. + """ + (tmp_path / "tb-examples.md").write_text("x") + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "nested.md").write_text("x") # skipped: not a top-level file + + assert _overlaid_doc_filenames(tmp_path) == ["tb-examples.md"] + + @pytest.mark.parametrize("edition", _editions()) @pytest.mark.parametrize("filename", _overlaid_filenames()) def test_edition_copy_matches_common(edition, filename): """Each committed edition copy is byte-identical to its common/ source.""" - source = _COMMON_DIR / filename - copy = _REPO_ROOT / edition / f"tb_{edition}_client" / filename - - assert copy.is_file(), f"{copy} is missing — run generate-client.sh" - assert copy.read_bytes() == source.read_bytes(), ( - f"{copy.relative_to(_REPO_ROOT)} is out of sync with common/{filename}. " + _assert_identical( + _COMMON_DIR / filename, + _REPO_ROOT / edition / f"tb_{edition}_client" / filename, f"Edit common/{filename} and re-run generate-client.sh (or copy it into " - f"every tb_*_client/ package)." + f"every tb_*_client/ package).", ) @@ -132,12 +156,9 @@ def test_edition_doc_copy_matches_common(edition, filename): /docs instead; without this, a hand-edit to one edition's copy of the shared documentation would pass CI unnoticed. """ - source = _COMMON_DIR / "docs" / filename - copy = _REPO_ROOT / edition / "docs" / filename - - assert copy.is_file(), f"{copy} is missing — run generate-client.sh" - assert copy.read_bytes() == source.read_bytes(), ( - f"{copy.relative_to(_REPO_ROOT)} is out of sync with common/docs/{filename}. " - f"Edit common/docs/{filename} and re-run generate-client.sh (or copy it into " - f"every /docs/ directory)." + _assert_identical( + _COMMON_DIR / _DOCS_DIRNAME / filename, + _REPO_ROOT / edition / _DOCS_DIRNAME / filename, + f"Edit common/{_DOCS_DIRNAME}/{filename} and re-run generate-client.sh (or copy " + f"it into every /{_DOCS_DIRNAME}/ directory).", ) diff --git a/tests/test_readme.py b/tests/test_readme.py index 66e0b6a8..73c382ac 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -116,26 +116,28 @@ def test_tb_examples_required_sections(): content = examples.read_text(encoding="utf-8") lower = content.lower() + # The JWT section is the one that needs both terms, so it stays its own assertion. assert "jwt" in lower and "login" in lower, ( "tb-examples.md missing JWT login section (must contain 'jwt' and 'login')" ) - assert "api key" in lower or "api_key" in lower, ( - "tb-examples.md missing API key login section (must contain 'api key' or 'api_key')" - ) - assert "device" in lower, "tb-examples.md missing device section (must contain 'device')" - assert "telemetry" in lower, ( - "tb-examples.md missing telemetry section (must contain 'telemetry')" - ) - assert "alarm" in lower, "tb-examples.md missing alarm section (must contain 'alarm')" - assert "with " in lower or "context manager" in lower, ( - "tb-examples.md missing with-statement section (must contain 'with ' or 'context manager')" - ) - assert "pre-existing token" in lower, ( - "tb-examples.md missing pre-existing token section (must contain 'pre-existing token')" - ) - assert "no authentication" in lower, ( - "tb-examples.md missing no-authentication section (must contain 'no authentication')" + + # Each row is one required section and the alternatives that satisfy it — any one + # is enough. Add a required section by adding a row. The two auth-mode sections are + # matched on their headings, since prose mentioning them in passing is not the point. + required_sections = ( + ("api key login", ("api key", "api_key")), + ("pre-existing token", ("## pre-existing token",)), + ("no authentication", ("## no authentication",)), + ("device", ("device",)), + ("telemetry", ("telemetry",)), + ("alarm", ("alarm",)), + ("with-statement", ("with ", "context manager")), ) + for name, alternatives in required_sections: + assert any(alt in lower for alt in alternatives), ( + f"tb-examples.md missing {name} section " + f"(must contain one of {', '.join(repr(a) for a in alternatives)})" + ) def test_tb_examples_code_blocks_valid_python(): From 5180dfaed45c4bc947f6d87835d1b7fb610540b6 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 14:57:26 +0300 Subject: [PATCH 08/17] Validate tb-examples at its common/ source rather than the ce copy test_readme.py checked ce/docs/tb-examples.md while common/docs/tb-examples.md is the file people edit. Now that test_common_overlay.py proves every /docs/ copy is byte-identical to it, pointing the content checks at the source covers all three editions instead of one, and fails where the edit would actually be made. --- tests/test_readme.py | 45 ++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/tests/test_readme.py b/tests/test_readme.py index 73c382ac..7a922838 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -1,11 +1,15 @@ """ -Tests validating README.md and ce/docs/tb-examples.md content. +Tests validating README.md and common/docs/tb-examples.md content. Validates: - README.md existence, quickstart section, code block syntax (DOC-01) - README.md uses keyword constructor form: username=... (DOC-01) -- ce/docs/tb-examples.md existence, required sections, code block syntax (DOC-04) -- ce/docs/tb-examples.md uses keyword constructor form: username=... (DOC-04) +- common/docs/tb-examples.md existence, required sections, code block syntax (DOC-04) +- common/docs/tb-examples.md uses keyword constructor form: username=... (DOC-04) + +The examples are checked at their source in common/docs/ rather than in one edition's +copy: that is the file people edit, and test_common_overlay.py already proves every +/docs/ copy is byte-identical to it, so all three editions are covered here. """ import ast @@ -13,6 +17,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).parent.parent +TB_EXAMPLES = REPO_ROOT / "common" / "docs" / "tb-examples.md" # --------------------------------------------------------------------------- @@ -99,20 +104,20 @@ def test_readme_uses_keyword_constructor(): # --------------------------------------------------------------------------- -# DOC-04: ce/docs/tb-examples.md tests +# DOC-04: common/docs/tb-examples.md tests # --------------------------------------------------------------------------- def test_tb_examples_exists(): - """ce/docs/tb-examples.md exists.""" - examples = REPO_ROOT / "ce" / "docs" / "tb-examples.md" - assert examples.is_file(), f"ce/docs/tb-examples.md does not exist at {examples}" + """common/docs/tb-examples.md exists.""" + examples = TB_EXAMPLES + assert examples.is_file(), f"common/docs/tb-examples.md does not exist at {examples}" def test_tb_examples_required_sections(): - """ce/docs/tb-examples.md contains all required operation sections.""" - examples = REPO_ROOT / "ce" / "docs" / "tb-examples.md" - assert examples.is_file(), "ce/docs/tb-examples.md does not exist" + """common/docs/tb-examples.md contains all required operation sections.""" + examples = TB_EXAMPLES + assert examples.is_file(), "common/docs/tb-examples.md does not exist" content = examples.read_text(encoding="utf-8") lower = content.lower() @@ -141,32 +146,32 @@ def test_tb_examples_required_sections(): def test_tb_examples_code_blocks_valid_python(): - """All Python code blocks in ce/docs/tb-examples.md are syntactically valid.""" - examples = REPO_ROOT / "ce" / "docs" / "tb-examples.md" - assert examples.is_file(), "ce/docs/tb-examples.md does not exist" + """All Python code blocks in common/docs/tb-examples.md are syntactically valid.""" + examples = TB_EXAMPLES + assert examples.is_file(), "common/docs/tb-examples.md does not exist" content = examples.read_text(encoding="utf-8") blocks = _extract_python_blocks(content) - assert blocks, "ce/docs/tb-examples.md has no Python code blocks" + assert blocks, "common/docs/tb-examples.md has no Python code blocks" errors = _validate_python_syntax(blocks) assert not errors, ( - "ce/docs/tb-examples.md has Python code blocks with syntax errors:\n" + "common/docs/tb-examples.md has Python code blocks with syntax errors:\n" + "\n".join(f" Block {i}: {msg}" for i, msg in errors) ) def test_tb_examples_uses_keyword_constructor(): - """ce/docs/tb-examples.md Python code blocks use keyword argument form (username=...).""" - examples = REPO_ROOT / "ce" / "docs" / "tb-examples.md" - assert examples.is_file(), "ce/docs/tb-examples.md does not exist" + """common/docs/tb-examples.md Python code blocks use keyword argument form (username=...).""" + examples = TB_EXAMPLES + assert examples.is_file(), "common/docs/tb-examples.md does not exist" content = examples.read_text(encoding="utf-8") blocks = _extract_python_blocks(content) - assert blocks, "ce/docs/tb-examples.md has no Python code blocks" + assert blocks, "common/docs/tb-examples.md has no Python code blocks" has_keyword_form = any("username=" in block for block in blocks) assert has_keyword_form, ( - "ce/docs/tb-examples.md has no Python code block containing 'username=' " + "common/docs/tb-examples.md has no Python code block containing 'username=' " "(must use keyword argument form, not positional)" ) From 40d19a2d6fed4d87fc501a3392cdff36760355ba Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 15:34:33 +0300 Subject: [PATCH 09/17] Address seventh review: symmetric docs discovery, heading-anchored sections - Replace the collection-time assert in _overlaid_doc_filenames with returning [], matching what rglob already does for _overlaid_filenames. The assert fired inside a parametrize decorator, so a missing common/docs/ errored the whole module and took the unrelated package-sync cases down with it; its remediation also pointed at generate-client.sh, which reads that directory rather than creating it. The discovery test is now the single reporter for both lists, and a fixture pins the degradation. - Heading-anchor the auth-mode and usage rows in test_readme.py: ("with ",) matched prose elsewhere in the file, so deleting the Context Manager section left the row passing. Folding JWT in removes the AND special case; operation rows stay substring-matched so a reworded heading does not fail them. - Widen refresh_token to "str | None" on set_external_token and _build_token_info, which have taken None since the coercion was dropped. - Generalise the header-slot assertion to _assert_header_slot(client, token, prefix) so the api_key test uses it too, and hoist it to the top of the class. - Compose the remediation inside _assert_identical and route both messages through as_posix(), so the same file is not reported two ways. - Drop the pure path aliases in test_readme.py and give README a constant too. - Correct the _DOCS_DIRNAME comment: the script hardcodes the destination separately, so the constant governs the source side only. --- ce/tb_ce_client/_auth.py | 4 +-- common/_auth.py | 4 +-- paas/tb_paas_client/_auth.py | 4 +-- pe/tb_pe_client/_auth.py | 4 +-- tests/test_client.py | 20 ++++++------- tests/test_common_overlay.py | 46 ++++++++++++++++++++--------- tests/test_readme.py | 56 ++++++++++++++++-------------------- 7 files changed, 74 insertions(+), 64 deletions(-) diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 7fef50b4..e751db77 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -146,7 +146,7 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str) self._password = password self._token_info = self._build_token_info(token, refresh_token) - def set_external_token(self, token: str, refresh_token=None) -> None: + def set_external_token(self, token: str, refresh_token: "str | None" = None) -> None: """Set a pre-existing token without storing login credentials. refresh_token is passed through as-is so that omitting it leaves @@ -264,7 +264,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") return json.loads(response.data) - def _build_token_info(self, token: str, refresh_token: str) -> "_TokenInfo": + def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo": """Parse JWT claims from token and refresh_token; compute clock_diff.""" now_ms = int(time.time() * 1000) token_exp = _parse_jwt_claim_ms(token, "exp") diff --git a/common/_auth.py b/common/_auth.py index 7fef50b4..e751db77 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -146,7 +146,7 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str) self._password = password self._token_info = self._build_token_info(token, refresh_token) - def set_external_token(self, token: str, refresh_token=None) -> None: + def set_external_token(self, token: str, refresh_token: "str | None" = None) -> None: """Set a pre-existing token without storing login credentials. refresh_token is passed through as-is so that omitting it leaves @@ -264,7 +264,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") return json.loads(response.data) - def _build_token_info(self, token: str, refresh_token: str) -> "_TokenInfo": + def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo": """Parse JWT claims from token and refresh_token; compute clock_diff.""" now_ms = int(time.time() * 1000) token_exp = _parse_jwt_claim_ms(token, "exp") diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 7fef50b4..e751db77 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -146,7 +146,7 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str) self._password = password self._token_info = self._build_token_info(token, refresh_token) - def set_external_token(self, token: str, refresh_token=None) -> None: + def set_external_token(self, token: str, refresh_token: "str | None" = None) -> None: """Set a pre-existing token without storing login credentials. refresh_token is passed through as-is so that omitting it leaves @@ -264,7 +264,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") return json.loads(response.data) - def _build_token_info(self, token: str, refresh_token: str) -> "_TokenInfo": + def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo": """Parse JWT claims from token and refresh_token; compute clock_diff.""" now_ms = int(time.time() * 1000) token_exp = _parse_jwt_claim_ms(token, "exp") diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 7fef50b4..e751db77 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -146,7 +146,7 @@ def on_login(self, username: str, password: str, token: str, refresh_token: str) self._password = password self._token_info = self._build_token_info(token, refresh_token) - def set_external_token(self, token: str, refresh_token=None) -> None: + def set_external_token(self, token: str, refresh_token: "str | None" = None) -> None: """Set a pre-existing token without storing login credentials. refresh_token is passed through as-is so that omitting it leaves @@ -264,7 +264,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") return json.loads(response.data) - def _build_token_info(self, token: str, refresh_token: str) -> "_TokenInfo": + def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo": """Parse JWT claims from token and refresh_token; compute clock_diff.""" now_ms = int(time.time() * 1000) token_exp = _parse_jwt_claim_ms(token, "exp") diff --git a/tests/test_client.py b/tests/test_client.py index c8ca0528..ba5fa251 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -39,6 +39,12 @@ def _logged_in_client(token="test.jwt.token", refresh_token="test.jwt.refresh"): class TestThingsboardClientJWTLogin(unittest.TestCase): """WRAP-01, AUTH-01 integration: username/password login flow.""" + def _assert_header_slot(self, client, token, prefix): + """The X-Authorization slot holds this token under this prefix.""" + cfg = client.api_client.configuration + self.assertEqual(cfg.api_key.get("ApiKeyForm"), token) + self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), prefix) + def test_jwt_login(self): """ThingsboardClient(url, username, password) calls login() and stores tokens.""" mock_resp = _mock_login_response() @@ -79,15 +85,7 @@ def test_api_key_auth(self): with patch(_LOGIN_PATCH_TARGET) as mock_login: client = ThingsboardClient(URL, api_key="test-key") mock_login.assert_not_called() - cfg = client.api_client.configuration - self.assertEqual(cfg.api_key.get("ApiKeyForm"), "test-key") - self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "ApiKey") - - def _assert_bearer_token_installed(self, client, token): - """The Bearer header slot is seeded from an externally supplied token.""" - cfg = client.api_client.configuration - self.assertEqual(cfg.api_key.get("ApiKeyForm"), token) - self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), "Bearer") + self._assert_header_slot(client, "test-key", "ApiKey") def test_preexisting_token(self): """WRAP-01, AUTH-06: pre-existing token sets header without login().""" @@ -96,7 +94,7 @@ def test_preexisting_token(self): URL, token="jwt.payload.sig", refresh_token="jwt.refresh.sig" ) mock_login.assert_not_called() - self._assert_bearer_token_installed(client, "jwt.payload.sig") + self._assert_header_slot(client, "jwt.payload.sig", "Bearer") self.assertEqual(client.get_refresh_token(), "jwt.refresh.sig") def test_preexisting_token_without_refresh_token(self): @@ -109,7 +107,7 @@ def test_preexisting_token_without_refresh_token(self): with patch(_LOGIN_PATCH_TARGET) as mock_login: client = ThingsboardClient(URL, token="jwt.payload.sig") mock_login.assert_not_called() - self._assert_bearer_token_installed(client, "jwt.payload.sig") + self._assert_header_slot(client, "jwt.payload.sig", "Bearer") self.assertIsNone(client.get_refresh_token()) def test_no_auth_leaves_header_slot_absent(self): diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 2e999f2b..f22e16f8 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -22,8 +22,10 @@ _COMMON_DIR = _REPO_ROOT / "common" # The directory generate-client.sh overlays into /docs rather than into the -# package. Named once because three things depend on it agreeing: the package check -# excludes it, and the docs check uses it as both source and destination. +# package. Named once for the two source-side uses — the package check excludes it and +# the docs check reads from it. The destination happens to share the name, but the +# script hardcodes that separately (`cp "$common_docs_dir/"* "$module_dir/docs/"`), so +# renaming this constant would not rename the edition directories. _DOCS_DIRNAME = "docs" # Excluded only where they sit at the top level of common/ — a nested file of the @@ -60,10 +62,16 @@ def _overlaid_doc_filenames(root: Path = _COMMON_DIR / _DOCS_DIRNAME) -> list[st Flat rather than recursive: the script overlays these with `cp common/docs/* ...`, which copies top-level entries only — and would abort on a subdirectory, since it - passes no -r. Missing the directory entirely is an error rather than an empty list, - so the check can't shrink to zero cases and pass vacuously. + passes no -r. + + A missing directory yields an empty list rather than raising, matching what rglob + does for _overlaid_filenames. Both then shrink to zero parametrized cases, and + test_discovery_finds_filenames_and_editions is the single place that reports it — + as a plain test failure, rather than a collection-time error that would take the + unrelated package-sync cases down with it. """ - assert root.is_dir(), f"{root} is missing — run generate-client.sh" + if not root.is_dir(): + return [] return sorted(p.name for p in root.iterdir() if p.is_file()) @@ -86,12 +94,21 @@ def test_discovery_finds_filenames_and_editions(): assert _editions(), "no editions parsed from generate-client.sh" -def _assert_identical(source: Path, copy: Path, remediation: str) -> None: - """Assert copy exists and is byte-identical to source, or explain how to fix it.""" - assert copy.is_file(), f"{copy} is missing — run generate-client.sh" +def _assert_identical(source: Path, copy: Path, destinations: str) -> None: + """Assert copy exists and is byte-identical to source, or explain how to fix it. + + destinations names where the source has to be copied to, e.g. "every tb_*_client/ + package" — the rest of the remediation is the same for both callers. + """ + source_rel = source.relative_to(_REPO_ROOT).as_posix() + copy_rel = copy.relative_to(_REPO_ROOT).as_posix() + remediation = ( + f"Edit {source_rel} and re-run generate-client.sh (or copy it into {destinations})." + ) + + assert copy.is_file(), f"{copy_rel} is missing. {remediation}" assert copy.read_bytes() == source.read_bytes(), ( - f"{copy.relative_to(_REPO_ROOT).as_posix()} is out of sync with " - f"{source.relative_to(_REPO_ROOT).as_posix()}. {remediation}" + f"{copy_rel} is out of sync with {source_rel}. {remediation}" ) @@ -133,6 +150,9 @@ def test_doc_walk_is_flat(tmp_path): (tmp_path / "sub" / "nested.md").write_text("x") # skipped: not a top-level file assert _overlaid_doc_filenames(tmp_path) == ["tb-examples.md"] + # A missing directory degrades to [] rather than raising, the same as rglob does + # for _overlaid_filenames; the discovery test is what turns that into a failure. + assert _overlaid_doc_filenames(tmp_path / "missing") == [] @pytest.mark.parametrize("edition", _editions()) @@ -142,8 +162,7 @@ def test_edition_copy_matches_common(edition, filename): _assert_identical( _COMMON_DIR / filename, _REPO_ROOT / edition / f"tb_{edition}_client" / filename, - f"Edit common/{filename} and re-run generate-client.sh (or copy it into " - f"every tb_*_client/ package).", + "every tb_*_client/ package", ) @@ -159,6 +178,5 @@ def test_edition_doc_copy_matches_common(edition, filename): _assert_identical( _COMMON_DIR / _DOCS_DIRNAME / filename, _REPO_ROOT / edition / _DOCS_DIRNAME / filename, - f"Edit common/{_DOCS_DIRNAME}/{filename} and re-run generate-client.sh (or copy " - f"it into every /{_DOCS_DIRNAME}/ directory).", + f"every /{_DOCS_DIRNAME}/ directory", ) diff --git a/tests/test_readme.py b/tests/test_readme.py index 7a922838..8d959b38 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -17,6 +17,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).parent.parent +README = REPO_ROOT / "README.md" TB_EXAMPLES = REPO_ROOT / "common" / "docs" / "tb-examples.md" @@ -56,15 +57,13 @@ def _validate_python_syntax(blocks: list) -> list: def test_readme_exists(): """README.md exists at the repository root.""" - readme = REPO_ROOT / "README.md" - assert readme.is_file(), f"README.md does not exist at {readme}" + assert README.is_file(), f"README.md does not exist at {README}" def test_readme_has_quickstart(): """README.md contains quickstart section with install, client, and error handling.""" - readme = REPO_ROOT / "README.md" - assert readme.is_file(), "README.md does not exist" - content = readme.read_text(encoding="utf-8") + assert README.is_file(), "README.md does not exist" + content = README.read_text(encoding="utf-8") assert "## Quickstart" in content, "README.md missing '## Quickstart' section heading" assert "pip install" in content, "README.md missing 'pip install' instruction" @@ -74,9 +73,8 @@ def test_readme_has_quickstart(): def test_readme_code_blocks_valid_python(): """All Python code blocks in README.md are syntactically valid.""" - readme = REPO_ROOT / "README.md" - assert readme.is_file(), "README.md does not exist" - content = readme.read_text(encoding="utf-8") + assert README.is_file(), "README.md does not exist" + content = README.read_text(encoding="utf-8") blocks = _extract_python_blocks(content) assert blocks, "README.md has no Python code blocks" @@ -89,9 +87,8 @@ def test_readme_code_blocks_valid_python(): def test_readme_uses_keyword_constructor(): """README.md Python code blocks use keyword argument form (username=...).""" - readme = REPO_ROOT / "README.md" - assert readme.is_file(), "README.md does not exist" - content = readme.read_text(encoding="utf-8") + assert README.is_file(), "README.md does not exist" + content = README.read_text(encoding="utf-8") blocks = _extract_python_blocks(content) assert blocks, "README.md has no Python code blocks" @@ -110,33 +107,32 @@ def test_readme_uses_keyword_constructor(): def test_tb_examples_exists(): """common/docs/tb-examples.md exists.""" - examples = TB_EXAMPLES - assert examples.is_file(), f"common/docs/tb-examples.md does not exist at {examples}" + assert TB_EXAMPLES.is_file(), f"common/docs/tb-examples.md does not exist at {TB_EXAMPLES}" def test_tb_examples_required_sections(): """common/docs/tb-examples.md contains all required operation sections.""" - examples = TB_EXAMPLES - assert examples.is_file(), "common/docs/tb-examples.md does not exist" - content = examples.read_text(encoding="utf-8") + assert TB_EXAMPLES.is_file(), "common/docs/tb-examples.md does not exist" + content = TB_EXAMPLES.read_text(encoding="utf-8") lower = content.lower() - # The JWT section is the one that needs both terms, so it stays its own assertion. - assert "jwt" in lower and "login" in lower, ( - "tb-examples.md missing JWT login section (must contain 'jwt' and 'login')" - ) - # Each row is one required section and the alternatives that satisfy it — any one - # is enough. Add a required section by adding a row. The two auth-mode sections are - # matched on their headings, since prose mentioning them in passing is not the point. + # is enough. Add a required section by adding a row. + # + # The auth-mode and usage sections are matched on their headings: their terms also + # occur in ordinary prose, so a substring would survive deleting the section itself + # (e.g. "for use with the /api/noauth endpoints" satisfies a bare "with "). The + # operation rows below stay substring-matched — those words appear only inside the + # sections they guard, and matching on content survives a heading being reworded. required_sections = ( - ("api key login", ("api key", "api_key")), + ("jwt login", ("## jwt login",)), + ("api key login", ("## api key login",)), ("pre-existing token", ("## pre-existing token",)), ("no authentication", ("## no authentication",)), + ("context manager", ("## context manager",)), ("device", ("device",)), ("telemetry", ("telemetry",)), ("alarm", ("alarm",)), - ("with-statement", ("with ", "context manager")), ) for name, alternatives in required_sections: assert any(alt in lower for alt in alternatives), ( @@ -147,9 +143,8 @@ def test_tb_examples_required_sections(): def test_tb_examples_code_blocks_valid_python(): """All Python code blocks in common/docs/tb-examples.md are syntactically valid.""" - examples = TB_EXAMPLES - assert examples.is_file(), "common/docs/tb-examples.md does not exist" - content = examples.read_text(encoding="utf-8") + assert TB_EXAMPLES.is_file(), "common/docs/tb-examples.md does not exist" + content = TB_EXAMPLES.read_text(encoding="utf-8") blocks = _extract_python_blocks(content) assert blocks, "common/docs/tb-examples.md has no Python code blocks" @@ -163,9 +158,8 @@ def test_tb_examples_code_blocks_valid_python(): def test_tb_examples_uses_keyword_constructor(): """common/docs/tb-examples.md Python code blocks use keyword argument form (username=...).""" - examples = TB_EXAMPLES - assert examples.is_file(), "common/docs/tb-examples.md does not exist" - content = examples.read_text(encoding="utf-8") + assert TB_EXAMPLES.is_file(), "common/docs/tb-examples.md does not exist" + content = TB_EXAMPLES.read_text(encoding="utf-8") blocks = _extract_python_blocks(content) assert blocks, "common/docs/tb-examples.md has no Python code blocks" From fe2be8b640bb1dc0d4b409072fcdc6cbed3464b4 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 15:54:27 +0300 Subject: [PATCH 10/17] Address eighth review: close the device-row hole, assert emitted headers The device row had the same hole this suite just closed for "with ", and the comment I added asserting otherwise was wrong: splitting the doc by heading shows "device" in six sections (Context Manager, Push Telemetry, Error Handling, Read/Save Attributes) besides its own, so deleting "## List Devices" left the row passing. Only telemetry and alarm were ever section-exclusive. - Anchor every required section on its heading and parametrize one case per heading, so the rule is uniform and a failure names the missing section. This also drops the alternatives tuple and the any(), which no row still used. - Assert the emitted auth_settings() value in _assert_header_slot. Nothing asserted the "ApiKey " header the api_key path actually sends; verified by mutating get_api_key_with_prefix to drop the prefix while leaving both slots correct, which now fails test_api_key_auth as well as the JWT cases. - Widen the implicit-Optional annotations one layer up, where callers see them: ThingsboardClient.__init__, _AuthManager.__init__ and _TokenInfo.__init__ still declared bare str for arguments defaulting to None. - Parametrize the code-block and keyword-constructor checks over both documents rather than writing each twice, so a rule added for one cannot miss the other, and drop the is_file() re-assertions the *_exists cases own. - Move the missing-directory case out of test_doc_walk_is_flat into its own test, parametrized over both walk helpers so the documented rglob parity is enforced rather than asserted in prose. - Phrase the shared remediation action-first, so it reads correctly for a missing copy where the source needs no edit. --- ce/tb_ce_client/_auth.py | 6 +- ce/tb_ce_client/client.py | 10 +-- common/_auth.py | 6 +- common/client.py | 10 +-- paas/tb_paas_client/_auth.py | 6 +- paas/tb_paas_client/client.py | 10 +-- pe/tb_pe_client/_auth.py | 6 +- pe/tb_pe_client/client.py | 10 +-- tests/test_client.py | 8 +- tests/test_common_overlay.py | 19 ++++- tests/test_readme.py | 152 ++++++++++++++-------------------- 11 files changed, 114 insertions(+), 129 deletions(-) diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index e751db77..ae5466f1 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -58,8 +58,8 @@ class _TokenInfo: def __init__( self, - token, - refresh_token, + token: "str | None", + refresh_token: "str | None", token_exp_ts: int, refresh_exp_ts: int, clock_diff: int, @@ -115,7 +115,7 @@ class _AuthManager: The auth mode is decided once in __init__ and never re-derived per request. """ - def __init__(self, base_url: str, api_key=None): + def __init__(self, base_url: str, api_key: "str | None" = None): """ Args: base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index cf2f4ea8..cd01fed9 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -66,11 +66,11 @@ class ThingsboardClient: def __init__( self, url: str, - username: str = None, - password: str = None, - api_key: str = None, - token: str = None, - refresh_token: str = None, + username: "str | None" = None, + password: "str | None" = None, + api_key: "str | None" = None, + token: "str | None" = None, + refresh_token: "str | None" = None, max_retries: int = 3, initial_retry_delay_ms: int = 1_000, max_retry_delay_ms: int = 30_000, diff --git a/common/_auth.py b/common/_auth.py index e751db77..ae5466f1 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -58,8 +58,8 @@ class _TokenInfo: def __init__( self, - token, - refresh_token, + token: "str | None", + refresh_token: "str | None", token_exp_ts: int, refresh_exp_ts: int, clock_diff: int, @@ -115,7 +115,7 @@ class _AuthManager: The auth mode is decided once in __init__ and never re-derived per request. """ - def __init__(self, base_url: str, api_key=None): + def __init__(self, base_url: str, api_key: "str | None" = None): """ Args: base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). diff --git a/common/client.py b/common/client.py index cf2f4ea8..cd01fed9 100644 --- a/common/client.py +++ b/common/client.py @@ -66,11 +66,11 @@ class ThingsboardClient: def __init__( self, url: str, - username: str = None, - password: str = None, - api_key: str = None, - token: str = None, - refresh_token: str = None, + username: "str | None" = None, + password: "str | None" = None, + api_key: "str | None" = None, + token: "str | None" = None, + refresh_token: "str | None" = None, max_retries: int = 3, initial_retry_delay_ms: int = 1_000, max_retry_delay_ms: int = 30_000, diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index e751db77..ae5466f1 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -58,8 +58,8 @@ class _TokenInfo: def __init__( self, - token, - refresh_token, + token: "str | None", + refresh_token: "str | None", token_exp_ts: int, refresh_exp_ts: int, clock_diff: int, @@ -115,7 +115,7 @@ class _AuthManager: The auth mode is decided once in __init__ and never re-derived per request. """ - def __init__(self, base_url: str, api_key=None): + def __init__(self, base_url: str, api_key: "str | None" = None): """ Args: base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index cf2f4ea8..cd01fed9 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -66,11 +66,11 @@ class ThingsboardClient: def __init__( self, url: str, - username: str = None, - password: str = None, - api_key: str = None, - token: str = None, - refresh_token: str = None, + username: "str | None" = None, + password: "str | None" = None, + api_key: "str | None" = None, + token: "str | None" = None, + refresh_token: "str | None" = None, max_retries: int = 3, initial_retry_delay_ms: int = 1_000, max_retry_delay_ms: int = 30_000, diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index e751db77..ae5466f1 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -58,8 +58,8 @@ class _TokenInfo: def __init__( self, - token, - refresh_token, + token: "str | None", + refresh_token: "str | None", token_exp_ts: int, refresh_exp_ts: int, clock_diff: int, @@ -115,7 +115,7 @@ class _AuthManager: The auth mode is decided once in __init__ and never re-derived per request. """ - def __init__(self, base_url: str, api_key=None): + def __init__(self, base_url: str, api_key: "str | None" = None): """ Args: base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index cf2f4ea8..cd01fed9 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -66,11 +66,11 @@ class ThingsboardClient: def __init__( self, url: str, - username: str = None, - password: str = None, - api_key: str = None, - token: str = None, - refresh_token: str = None, + username: "str | None" = None, + password: "str | None" = None, + api_key: "str | None" = None, + token: "str | None" = None, + refresh_token: "str | None" = None, max_retries: int = 3, initial_retry_delay_ms: int = 1_000, max_retry_delay_ms: int = 30_000, diff --git a/tests/test_client.py b/tests/test_client.py index ba5fa251..f17bd98c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -40,10 +40,16 @@ class TestThingsboardClientJWTLogin(unittest.TestCase): """WRAP-01, AUTH-01 integration: username/password login flow.""" def _assert_header_slot(self, client, token, prefix): - """The X-Authorization slot holds this token under this prefix.""" + """The X-Authorization slot holds this token, and emits it under this prefix. + + The auth_settings() assertion is the one a user observes — it is what an API + request actually sends. It runs the refresh hook, which is the real request + path rather than a pure state inspection. + """ cfg = client.api_client.configuration self.assertEqual(cfg.api_key.get("ApiKeyForm"), token) self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), prefix) + self.assertEqual(cfg.auth_settings()["ApiKeyForm"]["value"], f"{prefix} {token}") def test_jwt_login(self): """ThingsboardClient(url, username, password) calls login() and stores tokens.""" diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index f22e16f8..fdcb9a41 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -102,8 +102,10 @@ def _assert_identical(source: Path, copy: Path, destinations: str) -> None: """ source_rel = source.relative_to(_REPO_ROOT).as_posix() copy_rel = copy.relative_to(_REPO_ROOT).as_posix() + # Action-first, so it reads correctly for the missing-copy case too — there the + # source is presumably fine and re-running the script is the only step needed. remediation = ( - f"Edit {source_rel} and re-run generate-client.sh (or copy it into {destinations})." + f"Run generate-client.sh (or copy {source_rel} into {destinations}) after editing it." ) assert copy.is_file(), f"{copy_rel} is missing. {remediation}" @@ -150,9 +152,18 @@ def test_doc_walk_is_flat(tmp_path): (tmp_path / "sub" / "nested.md").write_text("x") # skipped: not a top-level file assert _overlaid_doc_filenames(tmp_path) == ["tb-examples.md"] - # A missing directory degrades to [] rather than raising, the same as rglob does - # for _overlaid_filenames; the discovery test is what turns that into a failure. - assert _overlaid_doc_filenames(tmp_path / "missing") == [] + + +@pytest.mark.parametrize("walk", (_overlaid_filenames, _overlaid_doc_filenames)) +def test_missing_directory_yields_empty_list(walk, tmp_path): + """Both helpers degrade to [] rather than raising when their root is absent. + + _overlaid_doc_filenames documents its guard as matching what rglob already does + for _overlaid_filenames; parametrizing over both makes that parity self-enforcing + instead of a claim in a docstring. The discovery test is what turns the empty list + into a failure — see the note on _overlaid_doc_filenames. + """ + assert walk(tmp_path / "missing") == [] @pytest.mark.parametrize("edition", _editions()) diff --git a/tests/test_readme.py b/tests/test_readme.py index 8d959b38..27d27b4f 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -10,16 +10,24 @@ The examples are checked at their source in common/docs/ rather than in one edition's copy: that is the file people edit, and test_common_overlay.py already proves every /docs/ copy is byte-identical to it, so all three editions are covered here. + +Checks that apply to both documents are parametrized over DOCUMENTS rather than written +twice, so a rule added for one cannot silently miss the other. """ import ast import re from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).parent.parent README = REPO_ROOT / "README.md" TB_EXAMPLES = REPO_ROOT / "common" / "docs" / "tb-examples.md" +DOCUMENTS = ((README, "README.md"), (TB_EXAMPLES, "common/docs/tb-examples.md")) +_DOCUMENT_IDS = [label for _, label in DOCUMENTS] + # --------------------------------------------------------------------------- # Helpers @@ -51,121 +59,81 @@ def _validate_python_syntax(blocks: list) -> list: # --------------------------------------------------------------------------- -# DOC-01: README.md tests +# DOC-01 / DOC-04: checks that apply to both documents # --------------------------------------------------------------------------- -def test_readme_exists(): - """README.md exists at the repository root.""" - assert README.is_file(), f"README.md does not exist at {README}" +@pytest.mark.parametrize("path,label", DOCUMENTS, ids=_DOCUMENT_IDS) +def test_document_exists(path, label): + """The document exists where the other tests expect to find it.""" + assert path.is_file(), f"{label} does not exist at {path}" -def test_readme_has_quickstart(): - """README.md contains quickstart section with install, client, and error handling.""" - assert README.is_file(), "README.md does not exist" - content = README.read_text(encoding="utf-8") - - assert "## Quickstart" in content, "README.md missing '## Quickstart' section heading" - assert "pip install" in content, "README.md missing 'pip install' instruction" - assert "ThingsboardClient" in content, "README.md missing 'ThingsboardClient' class name" - assert "ApiException" in content, "README.md missing 'ApiException' error handling" - - -def test_readme_code_blocks_valid_python(): - """All Python code blocks in README.md are syntactically valid.""" - assert README.is_file(), "README.md does not exist" - content = README.read_text(encoding="utf-8") - - blocks = _extract_python_blocks(content) - assert blocks, "README.md has no Python code blocks" +@pytest.mark.parametrize("path,label", DOCUMENTS, ids=_DOCUMENT_IDS) +def test_document_code_blocks_valid_python(path, label): + """All Python code blocks in the document are syntactically valid.""" + blocks = _extract_python_blocks(path.read_text(encoding="utf-8")) + assert blocks, f"{label} has no Python code blocks" errors = _validate_python_syntax(blocks) - assert not errors, "README.md has Python code blocks with syntax errors:\n" + "\n".join( + assert not errors, f"{label} has Python code blocks with syntax errors:\n" + "\n".join( f" Block {i}: {msg}" for i, msg in errors ) -def test_readme_uses_keyword_constructor(): - """README.md Python code blocks use keyword argument form (username=...).""" - assert README.is_file(), "README.md does not exist" - content = README.read_text(encoding="utf-8") - - blocks = _extract_python_blocks(content) - assert blocks, "README.md has no Python code blocks" +@pytest.mark.parametrize("path,label", DOCUMENTS, ids=_DOCUMENT_IDS) +def test_document_uses_keyword_constructor(path, label): + """The document's Python code blocks use keyword argument form (username=...).""" + blocks = _extract_python_blocks(path.read_text(encoding="utf-8")) + assert blocks, f"{label} has no Python code blocks" has_keyword_form = any("username=" in block for block in blocks) assert has_keyword_form, ( - "README.md has no Python code block containing 'username=' " + f"{label} has no Python code block containing 'username=' " "(must use keyword argument form, not positional)" ) # --------------------------------------------------------------------------- -# DOC-04: common/docs/tb-examples.md tests +# DOC-01: README.md only # --------------------------------------------------------------------------- -def test_tb_examples_exists(): - """common/docs/tb-examples.md exists.""" - assert TB_EXAMPLES.is_file(), f"common/docs/tb-examples.md does not exist at {TB_EXAMPLES}" - - -def test_tb_examples_required_sections(): - """common/docs/tb-examples.md contains all required operation sections.""" - assert TB_EXAMPLES.is_file(), "common/docs/tb-examples.md does not exist" - content = TB_EXAMPLES.read_text(encoding="utf-8") - lower = content.lower() - - # Each row is one required section and the alternatives that satisfy it — any one - # is enough. Add a required section by adding a row. - # - # The auth-mode and usage sections are matched on their headings: their terms also - # occur in ordinary prose, so a substring would survive deleting the section itself - # (e.g. "for use with the /api/noauth endpoints" satisfies a bare "with "). The - # operation rows below stay substring-matched — those words appear only inside the - # sections they guard, and matching on content survives a heading being reworded. - required_sections = ( - ("jwt login", ("## jwt login",)), - ("api key login", ("## api key login",)), - ("pre-existing token", ("## pre-existing token",)), - ("no authentication", ("## no authentication",)), - ("context manager", ("## context manager",)), - ("device", ("device",)), - ("telemetry", ("telemetry",)), - ("alarm", ("alarm",)), - ) - for name, alternatives in required_sections: - assert any(alt in lower for alt in alternatives), ( - f"tb-examples.md missing {name} section " - f"(must contain one of {', '.join(repr(a) for a in alternatives)})" - ) - - -def test_tb_examples_code_blocks_valid_python(): - """All Python code blocks in common/docs/tb-examples.md are syntactically valid.""" - assert TB_EXAMPLES.is_file(), "common/docs/tb-examples.md does not exist" - content = TB_EXAMPLES.read_text(encoding="utf-8") - - blocks = _extract_python_blocks(content) - assert blocks, "common/docs/tb-examples.md has no Python code blocks" - - errors = _validate_python_syntax(blocks) - assert not errors, ( - "common/docs/tb-examples.md has Python code blocks with syntax errors:\n" - + "\n".join(f" Block {i}: {msg}" for i, msg in errors) - ) +def test_readme_has_quickstart(): + """README.md contains quickstart section with install, client, and error handling.""" + content = README.read_text(encoding="utf-8") + assert "## Quickstart" in content, "README.md missing '## Quickstart' section heading" + assert "pip install" in content, "README.md missing 'pip install' instruction" + assert "ThingsboardClient" in content, "README.md missing 'ThingsboardClient' class name" + assert "ApiException" in content, "README.md missing 'ApiException' error handling" -def test_tb_examples_uses_keyword_constructor(): - """common/docs/tb-examples.md Python code blocks use keyword argument form (username=...).""" - assert TB_EXAMPLES.is_file(), "common/docs/tb-examples.md does not exist" - content = TB_EXAMPLES.read_text(encoding="utf-8") - blocks = _extract_python_blocks(content) - assert blocks, "common/docs/tb-examples.md has no Python code blocks" +# --------------------------------------------------------------------------- +# DOC-04: common/docs/tb-examples.md only +# --------------------------------------------------------------------------- - has_keyword_form = any("username=" in block for block in blocks) - assert has_keyword_form, ( - "common/docs/tb-examples.md has no Python code block containing 'username=' " - "(must use keyword argument form, not positional)" - ) +# Matched as headings rather than as bare words: the terms also occur in ordinary prose +# and in code samples elsewhere in the file, so a substring survives deleting the very +# section it is meant to guard. Both known cases were confirmed — "with " matches +# "…for use with the /api/noauth endpoints", and "device" appears in six sections +# (Context Manager, Push Telemetry, Error Handling, Read/Save Attributes) besides its +# own. Anchoring every row keeps the rule uniform rather than leaving the next reader to +# work out which words happen to be section-exclusive. +_REQUIRED_HEADINGS = ( + "## JWT Login", + "## API Key Login", + "## Pre-existing Token", + "## No Authentication", + "## Context Manager", + "## List Devices", + "## Push Telemetry", + "## List Alarms", +) + + +@pytest.mark.parametrize("heading", _REQUIRED_HEADINGS) +def test_tb_examples_required_sections(heading): + """common/docs/tb-examples.md contains each required section heading.""" + lower = TB_EXAMPLES.read_text(encoding="utf-8").lower() + assert heading.lower() in lower, f"common/docs/tb-examples.md missing '{heading}' section" From bbf84bf0a6175b00ddd99ea4aa40b5d71f055cd2 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Aug 2026 16:26:44 +0300 Subject: [PATCH 11/17] Address ninth review: anchor heading matches, finish the annotation pass The heading rows were tighter than substrings but looser than the tuple implied: both sides were case-folded and the match was unanchored, so "## list devices" is a substring of "### list devices" and a demotion to ### passed, as did any casing change. Now a case-sensitive anchored regex, verified by mutating both ways. - Correct the comment above _REQUIRED_HEADINGS: "device" occurs in five sections besides its own, not six, and the enumeration was archaeology from the previous two rounds. Trimmed to the durable reason. - Annotate _AuthManager.get_token/get_refresh_token as "str | None". They were the only methods in the class without a return annotation, and their ThingsboardClient wrappers were already better typed than what they delegate to. - Drop the "after editing it" tail from the shared remediation: it restated the premise the comment above it says does not hold on the missing-copy branch. - Derive document labels from the path with _label() instead of carrying a parallel string, so the id, the message and the path cannot disagree. - Extract _python_blocks(), the last copy-paste pair, and read each document once behind a cached _read(). - Assert the emitted header name in _assert_header_slot, so it covers what its docstring claims; test_jwt_login_emits_x_authorization_header collapses onto the helper and is kept for the login path, which no other caller exercises. - Trim test_missing_directory_yields_empty_list's docstring, which restated the helper's own almost sentence for sentence. --- ce/tb_ce_client/_auth.py | 4 +- common/_auth.py | 4 +- paas/tb_paas_client/_auth.py | 4 +- pe/tb_pe_client/_auth.py | 4 +- tests/test_client.py | 24 +++++++----- tests/test_common_overlay.py | 14 +++---- tests/test_readme.py | 76 ++++++++++++++++++++---------------- 7 files changed, 69 insertions(+), 61 deletions(-) diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index ae5466f1..bf344e35 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -154,11 +154,11 @@ def set_external_token(self, token: str, refresh_token: "str | None" = None) -> """ self._token_info = self._build_token_info(token, refresh_token) - def get_token(self): + def get_token(self) -> "str | None": """Return the current access token, or None if not yet set.""" return self._token_info.token - def get_refresh_token(self): + def get_refresh_token(self) -> "str | None": """Return the current refresh token, or None if not available.""" return self._token_info.refresh_token diff --git a/common/_auth.py b/common/_auth.py index ae5466f1..bf344e35 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -154,11 +154,11 @@ def set_external_token(self, token: str, refresh_token: "str | None" = None) -> """ self._token_info = self._build_token_info(token, refresh_token) - def get_token(self): + def get_token(self) -> "str | None": """Return the current access token, or None if not yet set.""" return self._token_info.token - def get_refresh_token(self): + def get_refresh_token(self) -> "str | None": """Return the current refresh token, or None if not available.""" return self._token_info.refresh_token diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index ae5466f1..bf344e35 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -154,11 +154,11 @@ def set_external_token(self, token: str, refresh_token: "str | None" = None) -> """ self._token_info = self._build_token_info(token, refresh_token) - def get_token(self): + def get_token(self) -> "str | None": """Return the current access token, or None if not yet set.""" return self._token_info.token - def get_refresh_token(self): + def get_refresh_token(self) -> "str | None": """Return the current refresh token, or None if not available.""" return self._token_info.refresh_token diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index ae5466f1..bf344e35 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -154,11 +154,11 @@ def set_external_token(self, token: str, refresh_token: "str | None" = None) -> """ self._token_info = self._build_token_info(token, refresh_token) - def get_token(self): + def get_token(self) -> "str | None": """Return the current access token, or None if not yet set.""" return self._token_info.token - def get_refresh_token(self): + def get_refresh_token(self) -> "str | None": """Return the current refresh token, or None if not available.""" return self._token_info.refresh_token diff --git a/tests/test_client.py b/tests/test_client.py index f17bd98c..628d1e33 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -42,14 +42,18 @@ class TestThingsboardClientJWTLogin(unittest.TestCase): def _assert_header_slot(self, client, token, prefix): """The X-Authorization slot holds this token, and emits it under this prefix. - The auth_settings() assertion is the one a user observes — it is what an API - request actually sends. It runs the refresh hook, which is the real request - path rather than a pure state inspection. + The auth_settings() assertions are the ones a user observes — they are the + header name and value an API request actually sends. Reading auth_settings() + runs the refresh hook, which is the real request path rather than a pure state + inspection. The header name is scheme-wide rather than per-mode, but asserting + it here is what makes the helper cover what its name claims. """ cfg = client.api_client.configuration self.assertEqual(cfg.api_key.get("ApiKeyForm"), token) self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), prefix) - self.assertEqual(cfg.auth_settings()["ApiKeyForm"]["value"], f"{prefix} {token}") + emitted = cfg.auth_settings()["ApiKeyForm"] + self.assertEqual(emitted["key"], "X-Authorization") + self.assertEqual(emitted["value"], f"{prefix} {token}") def test_jwt_login(self): """ThingsboardClient(url, username, password) calls login() and stores tokens.""" @@ -61,12 +65,12 @@ def test_jwt_login(self): self.assertEqual(client.get_token(), mock_resp.token) def test_jwt_login_emits_x_authorization_header(self): - """AUTH-01: auth_settings() yields the header an API request actually sends.""" - client = _logged_in_client() - auth = client.api_client.configuration.auth_settings() - self.assertIn("ApiKeyForm", auth) - self.assertEqual(auth["ApiKeyForm"]["key"], "X-Authorization") - self.assertEqual(auth["ApiKeyForm"]["value"], "Bearer test.jwt.token") + """AUTH-01: auth_settings() yields the header an API request actually sends. + + The login path is the one mode _assert_header_slot's other callers do not + cover — api_key=, token= and token-without-refresh all skip /api/auth/login. + """ + self._assert_header_slot(_logged_in_client(), "test.jwt.token", "Bearer") def test_jwt_header_follows_token_rotation(self): """AUTH-02: seeding at login does not freeze the first token. diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index fdcb9a41..58dc38b3 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -102,11 +102,9 @@ def _assert_identical(source: Path, copy: Path, destinations: str) -> None: """ source_rel = source.relative_to(_REPO_ROOT).as_posix() copy_rel = copy.relative_to(_REPO_ROOT).as_posix() - # Action-first, so it reads correctly for the missing-copy case too — there the - # source is presumably fine and re-running the script is the only step needed. - remediation = ( - f"Run generate-client.sh (or copy {source_rel} into {destinations}) after editing it." - ) + # Action-first and with no trailing "after editing it": on the missing-copy branch + # the source is fine and re-running the script is the only step needed. + remediation = f"Run generate-client.sh (or copy {source_rel} into {destinations})." assert copy.is_file(), f"{copy_rel} is missing. {remediation}" assert copy.read_bytes() == source.read_bytes(), ( @@ -158,10 +156,8 @@ def test_doc_walk_is_flat(tmp_path): def test_missing_directory_yields_empty_list(walk, tmp_path): """Both helpers degrade to [] rather than raising when their root is absent. - _overlaid_doc_filenames documents its guard as matching what rglob already does - for _overlaid_filenames; parametrizing over both makes that parity self-enforcing - instead of a claim in a docstring. The discovery test is what turns the empty list - into a failure — see the note on _overlaid_doc_filenames. + Parametrized over both so the parity _overlaid_doc_filenames claims in its + docstring is enforced rather than asserted. """ assert walk(tmp_path / "missing") == [] diff --git a/tests/test_readme.py b/tests/test_readme.py index 27d27b4f..60e0284e 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -17,6 +17,7 @@ import ast import re +from functools import cache from pathlib import Path import pytest @@ -25,8 +26,7 @@ README = REPO_ROOT / "README.md" TB_EXAMPLES = REPO_ROOT / "common" / "docs" / "tb-examples.md" -DOCUMENTS = ((README, "README.md"), (TB_EXAMPLES, "common/docs/tb-examples.md")) -_DOCUMENT_IDS = [label for _, label in DOCUMENTS] +DOCUMENTS = (README, TB_EXAMPLES) # --------------------------------------------------------------------------- @@ -34,13 +34,26 @@ # --------------------------------------------------------------------------- -def _extract_python_blocks(text: str) -> list: - """Return list of Python code block contents from a markdown string. +def _label(path: Path) -> str: + """Repo-relative name for a document, used for both test ids and messages.""" + return path.relative_to(REPO_ROOT).as_posix() - Finds all fenced code blocks marked with ```python ... ``` and returns - the text between the fences (excluding the fence lines themselves). + +@cache +def _read(path: Path) -> str: + """Read a document once per session — several tests read the same few files.""" + return path.read_text(encoding="utf-8") + + +def _python_blocks(path: Path) -> list: + """Return the document's ```python blocks, asserting it has at least one. + + The non-empty guard keeps a document that lost all its code samples from + vacuously satisfying the rules applied to those samples. """ - return re.findall(r"```python\n(.*?)```", text, re.DOTALL) + blocks = re.findall(r"```python\n(.*?)```", _read(path), re.DOTALL) + assert blocks, f"{_label(path)} has no Python code blocks" + return blocks def _validate_python_syntax(blocks: list) -> list: @@ -63,33 +76,27 @@ def _validate_python_syntax(blocks: list) -> list: # --------------------------------------------------------------------------- -@pytest.mark.parametrize("path,label", DOCUMENTS, ids=_DOCUMENT_IDS) -def test_document_exists(path, label): +@pytest.mark.parametrize("path", DOCUMENTS, ids=_label) +def test_document_exists(path): """The document exists where the other tests expect to find it.""" - assert path.is_file(), f"{label} does not exist at {path}" + assert path.is_file(), f"{_label(path)} does not exist at {path}" -@pytest.mark.parametrize("path,label", DOCUMENTS, ids=_DOCUMENT_IDS) -def test_document_code_blocks_valid_python(path, label): +@pytest.mark.parametrize("path", DOCUMENTS, ids=_label) +def test_document_code_blocks_valid_python(path): """All Python code blocks in the document are syntactically valid.""" - blocks = _extract_python_blocks(path.read_text(encoding="utf-8")) - assert blocks, f"{label} has no Python code blocks" - - errors = _validate_python_syntax(blocks) - assert not errors, f"{label} has Python code blocks with syntax errors:\n" + "\n".join( + errors = _validate_python_syntax(_python_blocks(path)) + assert not errors, f"{_label(path)} has Python code blocks with syntax errors:\n" + "\n".join( f" Block {i}: {msg}" for i, msg in errors ) -@pytest.mark.parametrize("path,label", DOCUMENTS, ids=_DOCUMENT_IDS) -def test_document_uses_keyword_constructor(path, label): +@pytest.mark.parametrize("path", DOCUMENTS, ids=_label) +def test_document_uses_keyword_constructor(path): """The document's Python code blocks use keyword argument form (username=...).""" - blocks = _extract_python_blocks(path.read_text(encoding="utf-8")) - assert blocks, f"{label} has no Python code blocks" - - has_keyword_form = any("username=" in block for block in blocks) + has_keyword_form = any("username=" in block for block in _python_blocks(path)) assert has_keyword_form, ( - f"{label} has no Python code block containing 'username=' " + f"{_label(path)} has no Python code block containing 'username=' " "(must use keyword argument form, not positional)" ) @@ -101,7 +108,7 @@ def test_document_uses_keyword_constructor(path, label): def test_readme_has_quickstart(): """README.md contains quickstart section with install, client, and error handling.""" - content = README.read_text(encoding="utf-8") + content = _read(README) assert "## Quickstart" in content, "README.md missing '## Quickstart' section heading" assert "pip install" in content, "README.md missing 'pip install' instruction" @@ -113,13 +120,9 @@ def test_readme_has_quickstart(): # DOC-04: common/docs/tb-examples.md only # --------------------------------------------------------------------------- -# Matched as headings rather than as bare words: the terms also occur in ordinary prose +# Matched as whole headings rather than as bare words: these terms also occur in prose # and in code samples elsewhere in the file, so a substring survives deleting the very -# section it is meant to guard. Both known cases were confirmed — "with " matches -# "…for use with the /api/noauth endpoints", and "device" appears in six sections -# (Context Manager, Push Telemetry, Error Handling, Read/Save Attributes) besides its -# own. Anchoring every row keeps the rule uniform rather than leaving the next reader to -# work out which words happen to be section-exclusive. +# section it is meant to guard. _REQUIRED_HEADINGS = ( "## JWT Login", "## API Key Login", @@ -134,6 +137,11 @@ def test_readme_has_quickstart(): @pytest.mark.parametrize("heading", _REQUIRED_HEADINGS) def test_tb_examples_required_sections(heading): - """common/docs/tb-examples.md contains each required section heading.""" - lower = TB_EXAMPLES.read_text(encoding="utf-8").lower() - assert heading.lower() in lower, f"common/docs/tb-examples.md missing '{heading}' section" + """common/docs/tb-examples.md contains each required section heading. + + Anchored and case-sensitive, so the assertion means what the tuple spells: a + demotion to '### ...' or a change of capitalization fails rather than passing + on a substring match. + """ + found = re.search(rf"^{re.escape(heading)}\s*$", _read(TB_EXAMPLES), re.MULTILINE) + assert found, f"{_label(TB_EXAMPLES)} missing '{heading}' section" From 028f86c68563521fd2b3d0f936f7b92e64b2987b Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 4 Aug 2026 08:30:04 +0300 Subject: [PATCH 12/17] Address tenth review: block waiters on in-flight refresh, reject empty credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _refresh_if_needed: a thread arriving mid-refresh now blocks on a threading.Condition until the in-flight refresh completes, instead of returning and sending the expired token it was replacing. Nothing retries the resulting 401 (_RetryingRESTClient only handles 429), so it surfaced as a spurious ApiException. Waiters take the refresher's outcome rather than retrying, so a failed round-trip is not multiplied by the number of waiting threads. The class docstring claimed this behaviour already; now it is true and pinned by a test. - Reject empty-string auth arguments: api_key="" passed every "is not None" check, installed no header and made the hook a no-op, yielding a client that silently sent no credentials — reachable from os.environ.get("TB_API_KEY", ""). - Extract _validate_auth_args() out of __init__, group the username/password rules together, and make the mutual-exclusion message name arguments as username=, api_key= and say which one to pass. - test_readme.py: route both documents' section checks through one anchored matcher, so README's Quickstart check is no longer a bare substring; pin the matcher's strictness and the no-blocks guard against fixtures; tighten \s*$ to [ \t]*$; rename _python_blocks to _require_python_blocks; drop the @cache. - Move _assert_header_slot to module level and rename its class, which had grown past "username/password login flow"; make the validation tests' patching consistent; trim docstrings that answered a reviewer rather than a maintainer. --- ce/tb_ce_client/_auth.py | 22 ++++++--- ce/tb_ce_client/client.py | 76 +++++++++++++++++++----------- common/_auth.py | 22 ++++++--- common/client.py | 76 +++++++++++++++++++----------- paas/tb_paas_client/_auth.py | 22 ++++++--- paas/tb_paas_client/client.py | 76 +++++++++++++++++++----------- pe/tb_pe_client/_auth.py | 22 ++++++--- pe/tb_pe_client/client.py | 76 +++++++++++++++++++----------- tests/test_auth.py | 59 ++++++++++++++++++++++++ tests/test_client.py | 87 +++++++++++++++++++++++------------ tests/test_common_overlay.py | 21 ++++----- tests/test_readme.py | 83 ++++++++++++++++++++++++--------- 12 files changed, 445 insertions(+), 197 deletions(-) diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index bf344e35..7b5ddb6a 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -108,9 +108,10 @@ class _AuthManager: Mirrors the Java ThingsboardClient.java inner AuthManager class. - Thread safety: a threading.Lock protects the _refreshing flag so that only - one concurrent API thread triggers a refresh call. Other threads wait at the - lock and skip the refresh once the first thread completes. + Thread safety: a threading.Condition guards the _refreshing flag so that only + one concurrent API thread performs a refresh. The others block until that + refresh finishes and then use its result — they must not proceed meanwhile, + since the token they would send is the expired one being replaced. The auth mode is decided once in __init__ and never re-derived per request. """ @@ -126,7 +127,7 @@ def __init__(self, base_url: str, api_key: "str | None" = None): self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX - self._lock = threading.Lock() + self._refresh_state = threading.Condition() self._refreshing = False self._username = None self._password = None @@ -197,9 +198,15 @@ def hook(self, configuration) -> None: def _refresh_if_needed(self) -> None: """Check token expiry and trigger refresh if the estimated server time exceeds the token expiry (with AVG_REQUEST_TIMEOUT buffer).""" - with self._lock: + with self._refresh_state: if self._refreshing: - # Another thread is already refreshing — skip + # Another thread is already refreshing. Block rather than return: + # returning here would send the expired token that thread is busy + # replacing, and nothing retries the resulting 401. + while self._refreshing: + self._refresh_state.wait() + # Its outcome is ours. Refreshing again on failure would multiply one + # failed round-trip by however many threads were waiting. return info = self._token_info if info.token is None or info.token_exp_ts < 0: @@ -222,8 +229,9 @@ def _refresh_if_needed(self) -> None: elif self._username: self._do_login() finally: - with self._lock: + with self._refresh_state: self._refreshing = False + self._refresh_state.notify_all() def _do_refresh_token(self, info: "_TokenInfo") -> None: """POST to /api/auth/token with the refresh token. Falls back to login on error.""" diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index cd01fed9..5a3d9187 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -35,6 +35,52 @@ from .models.login_request import LoginRequest +def _validate_auth_args(username, password, api_key, token, refresh_token) -> None: + """Reject auth argument combinations that cannot be honoured. + + Raises ValueError describing the offending arguments; returns None otherwise. + """ + # An empty string passes every "is not None" check below but installs no header, + # so the client would silently send no credentials at all — the failure mode this + # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). + for name, value in ( + ("username", username), + ("password", password), + ("api_key", api_key), + ("token", token), + ("refresh_token", refresh_token), + ): + if value is not None and not value: + raise ValueError(f"{name}= must not be empty") + + # The three auth modes share a single X-Authorization slot, so combining them is + # ambiguous: whichever ran last would win, and under api_key auth the refresh hook + # is a no-op, so a JWT installed alongside a key would be frozen at its initial + # value and never refreshed. + modes = [ + f"{name}=" + for name, value in (("username", username), ("api_key", api_key), ("token", token)) + if value is not None + ] + if len(modes) > 1: + raise ValueError( + "ThingsboardClient authentication modes are mutually exclusive; got " + f"{', '.join(modes)}. Pass exactly one of username=, api_key= or token=." + ) + + # password= is only read by the username branch, so on its own it would be silently + # dropped and surface later as a 401. username= alone is rejected here rather than + # left to LoginRequest, whose password is a required StrictStr — otherwise the + # caller gets a pydantic ValidationError from inside the generated model. + if password is not None and username is None: + raise ValueError("password= requires username=") + if username is not None and password is None: + raise ValueError("username= requires password=") + # refresh_token= is likewise read only by the token branch. + if refresh_token is not None and token is None: + raise ValueError("refresh_token= requires token=") + + class ThingsboardClient: """User-facing ThingsBoard client. @@ -94,37 +140,15 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; - if password= is given without username= or vice versa; or if - refresh_token= is given without token=. + if password= is given without username= or vice versa; if + refresh_token= is given without token=; or if any auth argument is + an empty string. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} - # The three auth modes share a single X-Authorization slot, so combining - # them is ambiguous: whichever ran last would win, and under api_key auth - # the refresh hook is a no-op, so a JWT installed alongside a key would be - # frozen at its initial value and never refreshed. - modes = [ - name - for name, value in (("username", username), ("api_key", api_key), ("token", token)) - if value is not None - ] - if len(modes) > 1: - raise ValueError( - "ThingsboardClient authentication modes are mutually exclusive; " - f"got {', '.join(modes)}" - ) - # password= and refresh_token= are only read by their own mode's branch, so - # on their own they would be silently dropped and surface later as a 401. - if password is not None and username is None: - raise ValueError("password= requires username=") - if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=") - # LoginRequest.password is a required StrictStr, so without this the caller - # gets a pydantic ValidationError from inside the generated model instead. - if username is not None and password is None: - raise ValueError("username= requires password=") + _validate_auth_args(username, password, api_key, token, refresh_token) configuration = Configuration(host=url) diff --git a/common/_auth.py b/common/_auth.py index bf344e35..7b5ddb6a 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -108,9 +108,10 @@ class _AuthManager: Mirrors the Java ThingsboardClient.java inner AuthManager class. - Thread safety: a threading.Lock protects the _refreshing flag so that only - one concurrent API thread triggers a refresh call. Other threads wait at the - lock and skip the refresh once the first thread completes. + Thread safety: a threading.Condition guards the _refreshing flag so that only + one concurrent API thread performs a refresh. The others block until that + refresh finishes and then use its result — they must not proceed meanwhile, + since the token they would send is the expired one being replaced. The auth mode is decided once in __init__ and never re-derived per request. """ @@ -126,7 +127,7 @@ def __init__(self, base_url: str, api_key: "str | None" = None): self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX - self._lock = threading.Lock() + self._refresh_state = threading.Condition() self._refreshing = False self._username = None self._password = None @@ -197,9 +198,15 @@ def hook(self, configuration) -> None: def _refresh_if_needed(self) -> None: """Check token expiry and trigger refresh if the estimated server time exceeds the token expiry (with AVG_REQUEST_TIMEOUT buffer).""" - with self._lock: + with self._refresh_state: if self._refreshing: - # Another thread is already refreshing — skip + # Another thread is already refreshing. Block rather than return: + # returning here would send the expired token that thread is busy + # replacing, and nothing retries the resulting 401. + while self._refreshing: + self._refresh_state.wait() + # Its outcome is ours. Refreshing again on failure would multiply one + # failed round-trip by however many threads were waiting. return info = self._token_info if info.token is None or info.token_exp_ts < 0: @@ -222,8 +229,9 @@ def _refresh_if_needed(self) -> None: elif self._username: self._do_login() finally: - with self._lock: + with self._refresh_state: self._refreshing = False + self._refresh_state.notify_all() def _do_refresh_token(self, info: "_TokenInfo") -> None: """POST to /api/auth/token with the refresh token. Falls back to login on error.""" diff --git a/common/client.py b/common/client.py index cd01fed9..5a3d9187 100644 --- a/common/client.py +++ b/common/client.py @@ -35,6 +35,52 @@ from .models.login_request import LoginRequest +def _validate_auth_args(username, password, api_key, token, refresh_token) -> None: + """Reject auth argument combinations that cannot be honoured. + + Raises ValueError describing the offending arguments; returns None otherwise. + """ + # An empty string passes every "is not None" check below but installs no header, + # so the client would silently send no credentials at all — the failure mode this + # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). + for name, value in ( + ("username", username), + ("password", password), + ("api_key", api_key), + ("token", token), + ("refresh_token", refresh_token), + ): + if value is not None and not value: + raise ValueError(f"{name}= must not be empty") + + # The three auth modes share a single X-Authorization slot, so combining them is + # ambiguous: whichever ran last would win, and under api_key auth the refresh hook + # is a no-op, so a JWT installed alongside a key would be frozen at its initial + # value and never refreshed. + modes = [ + f"{name}=" + for name, value in (("username", username), ("api_key", api_key), ("token", token)) + if value is not None + ] + if len(modes) > 1: + raise ValueError( + "ThingsboardClient authentication modes are mutually exclusive; got " + f"{', '.join(modes)}. Pass exactly one of username=, api_key= or token=." + ) + + # password= is only read by the username branch, so on its own it would be silently + # dropped and surface later as a 401. username= alone is rejected here rather than + # left to LoginRequest, whose password is a required StrictStr — otherwise the + # caller gets a pydantic ValidationError from inside the generated model. + if password is not None and username is None: + raise ValueError("password= requires username=") + if username is not None and password is None: + raise ValueError("username= requires password=") + # refresh_token= is likewise read only by the token branch. + if refresh_token is not None and token is None: + raise ValueError("refresh_token= requires token=") + + class ThingsboardClient: """User-facing ThingsBoard client. @@ -94,37 +140,15 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; - if password= is given without username= or vice versa; or if - refresh_token= is given without token=. + if password= is given without username= or vice versa; if + refresh_token= is given without token=; or if any auth argument is + an empty string. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} - # The three auth modes share a single X-Authorization slot, so combining - # them is ambiguous: whichever ran last would win, and under api_key auth - # the refresh hook is a no-op, so a JWT installed alongside a key would be - # frozen at its initial value and never refreshed. - modes = [ - name - for name, value in (("username", username), ("api_key", api_key), ("token", token)) - if value is not None - ] - if len(modes) > 1: - raise ValueError( - "ThingsboardClient authentication modes are mutually exclusive; " - f"got {', '.join(modes)}" - ) - # password= and refresh_token= are only read by their own mode's branch, so - # on their own they would be silently dropped and surface later as a 401. - if password is not None and username is None: - raise ValueError("password= requires username=") - if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=") - # LoginRequest.password is a required StrictStr, so without this the caller - # gets a pydantic ValidationError from inside the generated model instead. - if username is not None and password is None: - raise ValueError("username= requires password=") + _validate_auth_args(username, password, api_key, token, refresh_token) configuration = Configuration(host=url) diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index bf344e35..7b5ddb6a 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -108,9 +108,10 @@ class _AuthManager: Mirrors the Java ThingsboardClient.java inner AuthManager class. - Thread safety: a threading.Lock protects the _refreshing flag so that only - one concurrent API thread triggers a refresh call. Other threads wait at the - lock and skip the refresh once the first thread completes. + Thread safety: a threading.Condition guards the _refreshing flag so that only + one concurrent API thread performs a refresh. The others block until that + refresh finishes and then use its result — they must not proceed meanwhile, + since the token they would send is the expired one being replaced. The auth mode is decided once in __init__ and never re-derived per request. """ @@ -126,7 +127,7 @@ def __init__(self, base_url: str, api_key: "str | None" = None): self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX - self._lock = threading.Lock() + self._refresh_state = threading.Condition() self._refreshing = False self._username = None self._password = None @@ -197,9 +198,15 @@ def hook(self, configuration) -> None: def _refresh_if_needed(self) -> None: """Check token expiry and trigger refresh if the estimated server time exceeds the token expiry (with AVG_REQUEST_TIMEOUT buffer).""" - with self._lock: + with self._refresh_state: if self._refreshing: - # Another thread is already refreshing — skip + # Another thread is already refreshing. Block rather than return: + # returning here would send the expired token that thread is busy + # replacing, and nothing retries the resulting 401. + while self._refreshing: + self._refresh_state.wait() + # Its outcome is ours. Refreshing again on failure would multiply one + # failed round-trip by however many threads were waiting. return info = self._token_info if info.token is None or info.token_exp_ts < 0: @@ -222,8 +229,9 @@ def _refresh_if_needed(self) -> None: elif self._username: self._do_login() finally: - with self._lock: + with self._refresh_state: self._refreshing = False + self._refresh_state.notify_all() def _do_refresh_token(self, info: "_TokenInfo") -> None: """POST to /api/auth/token with the refresh token. Falls back to login on error.""" diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index cd01fed9..5a3d9187 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -35,6 +35,52 @@ from .models.login_request import LoginRequest +def _validate_auth_args(username, password, api_key, token, refresh_token) -> None: + """Reject auth argument combinations that cannot be honoured. + + Raises ValueError describing the offending arguments; returns None otherwise. + """ + # An empty string passes every "is not None" check below but installs no header, + # so the client would silently send no credentials at all — the failure mode this + # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). + for name, value in ( + ("username", username), + ("password", password), + ("api_key", api_key), + ("token", token), + ("refresh_token", refresh_token), + ): + if value is not None and not value: + raise ValueError(f"{name}= must not be empty") + + # The three auth modes share a single X-Authorization slot, so combining them is + # ambiguous: whichever ran last would win, and under api_key auth the refresh hook + # is a no-op, so a JWT installed alongside a key would be frozen at its initial + # value and never refreshed. + modes = [ + f"{name}=" + for name, value in (("username", username), ("api_key", api_key), ("token", token)) + if value is not None + ] + if len(modes) > 1: + raise ValueError( + "ThingsboardClient authentication modes are mutually exclusive; got " + f"{', '.join(modes)}. Pass exactly one of username=, api_key= or token=." + ) + + # password= is only read by the username branch, so on its own it would be silently + # dropped and surface later as a 401. username= alone is rejected here rather than + # left to LoginRequest, whose password is a required StrictStr — otherwise the + # caller gets a pydantic ValidationError from inside the generated model. + if password is not None and username is None: + raise ValueError("password= requires username=") + if username is not None and password is None: + raise ValueError("username= requires password=") + # refresh_token= is likewise read only by the token branch. + if refresh_token is not None and token is None: + raise ValueError("refresh_token= requires token=") + + class ThingsboardClient: """User-facing ThingsBoard client. @@ -94,37 +140,15 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; - if password= is given without username= or vice versa; or if - refresh_token= is given without token=. + if password= is given without username= or vice versa; if + refresh_token= is given without token=; or if any auth argument is + an empty string. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} - # The three auth modes share a single X-Authorization slot, so combining - # them is ambiguous: whichever ran last would win, and under api_key auth - # the refresh hook is a no-op, so a JWT installed alongside a key would be - # frozen at its initial value and never refreshed. - modes = [ - name - for name, value in (("username", username), ("api_key", api_key), ("token", token)) - if value is not None - ] - if len(modes) > 1: - raise ValueError( - "ThingsboardClient authentication modes are mutually exclusive; " - f"got {', '.join(modes)}" - ) - # password= and refresh_token= are only read by their own mode's branch, so - # on their own they would be silently dropped and surface later as a 401. - if password is not None and username is None: - raise ValueError("password= requires username=") - if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=") - # LoginRequest.password is a required StrictStr, so without this the caller - # gets a pydantic ValidationError from inside the generated model instead. - if username is not None and password is None: - raise ValueError("username= requires password=") + _validate_auth_args(username, password, api_key, token, refresh_token) configuration = Configuration(host=url) diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index bf344e35..7b5ddb6a 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -108,9 +108,10 @@ class _AuthManager: Mirrors the Java ThingsboardClient.java inner AuthManager class. - Thread safety: a threading.Lock protects the _refreshing flag so that only - one concurrent API thread triggers a refresh call. Other threads wait at the - lock and skip the refresh once the first thread completes. + Thread safety: a threading.Condition guards the _refreshing flag so that only + one concurrent API thread performs a refresh. The others block until that + refresh finishes and then use its result — they must not proceed meanwhile, + since the token they would send is the expired one being replaced. The auth mode is decided once in __init__ and never re-derived per request. """ @@ -126,7 +127,7 @@ def __init__(self, base_url: str, api_key: "str | None" = None): self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX - self._lock = threading.Lock() + self._refresh_state = threading.Condition() self._refreshing = False self._username = None self._password = None @@ -197,9 +198,15 @@ def hook(self, configuration) -> None: def _refresh_if_needed(self) -> None: """Check token expiry and trigger refresh if the estimated server time exceeds the token expiry (with AVG_REQUEST_TIMEOUT buffer).""" - with self._lock: + with self._refresh_state: if self._refreshing: - # Another thread is already refreshing — skip + # Another thread is already refreshing. Block rather than return: + # returning here would send the expired token that thread is busy + # replacing, and nothing retries the resulting 401. + while self._refreshing: + self._refresh_state.wait() + # Its outcome is ours. Refreshing again on failure would multiply one + # failed round-trip by however many threads were waiting. return info = self._token_info if info.token is None or info.token_exp_ts < 0: @@ -222,8 +229,9 @@ def _refresh_if_needed(self) -> None: elif self._username: self._do_login() finally: - with self._lock: + with self._refresh_state: self._refreshing = False + self._refresh_state.notify_all() def _do_refresh_token(self, info: "_TokenInfo") -> None: """POST to /api/auth/token with the refresh token. Falls back to login on error.""" diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index cd01fed9..5a3d9187 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -35,6 +35,52 @@ from .models.login_request import LoginRequest +def _validate_auth_args(username, password, api_key, token, refresh_token) -> None: + """Reject auth argument combinations that cannot be honoured. + + Raises ValueError describing the offending arguments; returns None otherwise. + """ + # An empty string passes every "is not None" check below but installs no header, + # so the client would silently send no credentials at all — the failure mode this + # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). + for name, value in ( + ("username", username), + ("password", password), + ("api_key", api_key), + ("token", token), + ("refresh_token", refresh_token), + ): + if value is not None and not value: + raise ValueError(f"{name}= must not be empty") + + # The three auth modes share a single X-Authorization slot, so combining them is + # ambiguous: whichever ran last would win, and under api_key auth the refresh hook + # is a no-op, so a JWT installed alongside a key would be frozen at its initial + # value and never refreshed. + modes = [ + f"{name}=" + for name, value in (("username", username), ("api_key", api_key), ("token", token)) + if value is not None + ] + if len(modes) > 1: + raise ValueError( + "ThingsboardClient authentication modes are mutually exclusive; got " + f"{', '.join(modes)}. Pass exactly one of username=, api_key= or token=." + ) + + # password= is only read by the username branch, so on its own it would be silently + # dropped and surface later as a 401. username= alone is rejected here rather than + # left to LoginRequest, whose password is a required StrictStr — otherwise the + # caller gets a pydantic ValidationError from inside the generated model. + if password is not None and username is None: + raise ValueError("password= requires username=") + if username is not None and password is None: + raise ValueError("username= requires password=") + # refresh_token= is likewise read only by the token branch. + if refresh_token is not None and token is None: + raise ValueError("refresh_token= requires token=") + + class ThingsboardClient: """User-facing ThingsBoard client. @@ -94,37 +140,15 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; - if password= is given without username= or vice versa; or if - refresh_token= is given without token=. + if password= is given without username= or vice versa; if + refresh_token= is given without token=; or if any auth argument is + an empty string. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} - # The three auth modes share a single X-Authorization slot, so combining - # them is ambiguous: whichever ran last would win, and under api_key auth - # the refresh hook is a no-op, so a JWT installed alongside a key would be - # frozen at its initial value and never refreshed. - modes = [ - name - for name, value in (("username", username), ("api_key", api_key), ("token", token)) - if value is not None - ] - if len(modes) > 1: - raise ValueError( - "ThingsboardClient authentication modes are mutually exclusive; " - f"got {', '.join(modes)}" - ) - # password= and refresh_token= are only read by their own mode's branch, so - # on their own they would be silently dropped and surface later as a 401. - if password is not None and username is None: - raise ValueError("password= requires username=") - if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=") - # LoginRequest.password is a required StrictStr, so without this the caller - # gets a pydantic ValidationError from inside the generated model instead. - if username is not None and password is None: - raise ValueError("username= requires password=") + _validate_auth_args(username, password, api_key, token, refresh_token) configuration = Configuration(host=url) diff --git a/tests/test_auth.py b/tests/test_auth.py index 7a0fefe1..e257af58 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,6 +3,7 @@ Covers AUTH-01 through AUTH-06 requirements. """ +import threading import time import unittest from unittest.mock import MagicMock, patch @@ -212,6 +213,64 @@ def side_effect(path, body): self.assertEqual(config.api_key["ApiKeyForm"], new_token) +# --------------------------------------------------------------------------- +# Concurrent refresh +# --------------------------------------------------------------------------- + + +class TestConcurrentRefresh(unittest.TestCase): + def test_second_thread_waits_for_in_flight_refresh(self): + """A thread arriving mid-refresh blocks, then sends the refreshed token. + + Returning early instead would install the expired token the in-flight refresh + is replacing, and nothing retries the resulting 401 — _RetryingRESTClient only + handles 429 — so it would surface to the caller as a spurious ApiException. + """ + auth = _AuthManager("http://tb:9090") + auth.on_login( + "user@tb.io", + "password", + make_token(exp_offset_s=-3600), + make_refresh_token(exp_offset_s=86400), + ) + new_token = make_token(exp_offset_s=7200) + + refresh_started = threading.Event() + release_refresh = threading.Event() + posts = [] + + def blocking_post(path, _body): + posts.append(path) + refresh_started.set() + release_refresh.wait(timeout=5) + return {"token": new_token, "refreshToken": make_refresh_token(exp_offset_s=172800)} + + configs = {} + + def run_hook(name): + configs[name] = _mock_configuration() + auth.hook(configs[name]) + + with patch.object(auth, "_raw_post", side_effect=blocking_post): + first = threading.Thread(target=run_hook, args=("first",)) + first.start() + self.assertTrue(refresh_started.wait(timeout=5), "first thread never refreshed") + + second = threading.Thread(target=run_hook, args=("second",)) + second.start() + second.join(timeout=0.2) + self.assertTrue(second.is_alive(), "second thread did not wait for the refresh") + + release_refresh.set() + first.join(timeout=5) + second.join(timeout=5) + + # One round-trip, not one per waiting thread, and both threads send its result. + self.assertEqual(posts, ["/api/auth/token"]) + self.assertEqual(configs["first"].api_key["ApiKeyForm"], new_token) + self.assertEqual(configs["second"].api_key["ApiKeyForm"], new_token) + + # --------------------------------------------------------------------------- # AUTH-05: API key passthrough # --------------------------------------------------------------------------- diff --git a/tests/test_client.py b/tests/test_client.py index 628d1e33..7564cc5c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -36,24 +36,22 @@ def _logged_in_client(token="test.jwt.token", refresh_token="test.jwt.refresh"): return ThingsboardClient(URL, "user@tb.io", "pass123") -class TestThingsboardClientJWTLogin(unittest.TestCase): - """WRAP-01, AUTH-01 integration: username/password login flow.""" +def _assert_header_slot(case, client, token, prefix): + """The X-Authorization slot holds this token, and emits it under this prefix. - def _assert_header_slot(self, client, token, prefix): - """The X-Authorization slot holds this token, and emits it under this prefix. + Reading auth_settings() runs the refresh hook, so these are the header name and + value an API request actually sends rather than a pure state inspection. + """ + cfg = client.api_client.configuration + case.assertEqual(cfg.api_key.get("ApiKeyForm"), token) + case.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), prefix) + emitted = cfg.auth_settings()["ApiKeyForm"] + case.assertEqual(emitted["key"], "X-Authorization") + case.assertEqual(emitted["value"], f"{prefix} {token}") - The auth_settings() assertions are the ones a user observes — they are the - header name and value an API request actually sends. Reading auth_settings() - runs the refresh hook, which is the real request path rather than a pure state - inspection. The header name is scheme-wide rather than per-mode, but asserting - it here is what makes the helper cover what its name claims. - """ - cfg = client.api_client.configuration - self.assertEqual(cfg.api_key.get("ApiKeyForm"), token) - self.assertEqual(cfg.api_key_prefix.get("ApiKeyForm"), prefix) - emitted = cfg.auth_settings()["ApiKeyForm"] - self.assertEqual(emitted["key"], "X-Authorization") - self.assertEqual(emitted["value"], f"{prefix} {token}") + +class TestThingsboardClientAuthModes(unittest.TestCase): + """WRAP-01, AUTH-01/05/06 integration: every auth mode, plus unauthenticated.""" def test_jwt_login(self): """ThingsboardClient(url, username, password) calls login() and stores tokens.""" @@ -67,10 +65,9 @@ def test_jwt_login(self): def test_jwt_login_emits_x_authorization_header(self): """AUTH-01: auth_settings() yields the header an API request actually sends. - The login path is the one mode _assert_header_slot's other callers do not - cover — api_key=, token= and token-without-refresh all skip /api/auth/login. + Covers the username/password mode, the one path that calls /api/auth/login. """ - self._assert_header_slot(_logged_in_client(), "test.jwt.token", "Bearer") + _assert_header_slot(self, _logged_in_client(), "test.jwt.token", "Bearer") def test_jwt_header_follows_token_rotation(self): """AUTH-02: seeding at login does not freeze the first token. @@ -95,7 +92,7 @@ def test_api_key_auth(self): with patch(_LOGIN_PATCH_TARGET) as mock_login: client = ThingsboardClient(URL, api_key="test-key") mock_login.assert_not_called() - self._assert_header_slot(client, "test-key", "ApiKey") + _assert_header_slot(self, client, "test-key", "ApiKey") def test_preexisting_token(self): """WRAP-01, AUTH-06: pre-existing token sets header without login().""" @@ -104,7 +101,7 @@ def test_preexisting_token(self): URL, token="jwt.payload.sig", refresh_token="jwt.refresh.sig" ) mock_login.assert_not_called() - self._assert_header_slot(client, "jwt.payload.sig", "Bearer") + _assert_header_slot(self, client, "jwt.payload.sig", "Bearer") self.assertEqual(client.get_refresh_token(), "jwt.refresh.sig") def test_preexisting_token_without_refresh_token(self): @@ -117,7 +114,7 @@ def test_preexisting_token_without_refresh_token(self): with patch(_LOGIN_PATCH_TARGET) as mock_login: client = ThingsboardClient(URL, token="jwt.payload.sig") mock_login.assert_not_called() - self._assert_header_slot(client, "jwt.payload.sig", "Bearer") + _assert_header_slot(self, client, "jwt.payload.sig", "Bearer") self.assertIsNone(client.get_refresh_token()) def test_no_auth_leaves_header_slot_absent(self): @@ -135,7 +132,12 @@ def test_no_auth_leaves_header_slot_absent(self): class TestThingsboardClientAuthArgValidation(unittest.TestCase): - """The three auth modes share one X-Authorization slot, so mixing them is rejected.""" + """Auth argument combinations that cannot be honoured are rejected in __init__. + + Cases that pass username= patch the login endpoint and assert it was not called, + which is what proves validation runs before the network. The rest cannot reach + login at any point, so the raise is the whole assertion. + """ def test_api_key_with_username_rejected(self): """api_key= plus username= raises before any login call is made. @@ -145,28 +147,37 @@ def test_api_key_with_username_rejected(self): failing with 401 once it expired. """ with patch(_LOGIN_PATCH_TARGET) as mock_login: - with self.assertRaisesRegex(ValueError, "username, api_key"): + with self.assertRaisesRegex(ValueError, "username=, api_key="): ThingsboardClient(URL, "user@tb.io", "pass123", api_key="test-key") mock_login.assert_not_called() def test_api_key_with_token_rejected(self): """api_key= plus token= raises.""" - with self.assertRaisesRegex(ValueError, "api_key, token"): + with self.assertRaisesRegex(ValueError, "api_key=, token="): ThingsboardClient(URL, api_key="test-key", token="jwt.payload.sig") def test_username_with_token_rejected(self): """username= plus token= raises.""" - with patch(_LOGIN_PATCH_TARGET): - with self.assertRaisesRegex(ValueError, "username, token"): + with patch(_LOGIN_PATCH_TARGET) as mock_login: + with self.assertRaisesRegex(ValueError, "username=, token="): ThingsboardClient(URL, "user@tb.io", "pass123", token="jwt.payload.sig") + mock_login.assert_not_called() def test_all_three_modes_rejected(self): """All three at once raises and the message names every colliding mode.""" - with patch(_LOGIN_PATCH_TARGET): - with self.assertRaisesRegex(ValueError, "username, api_key, token"): + with patch(_LOGIN_PATCH_TARGET) as mock_login: + with self.assertRaisesRegex(ValueError, "username=, api_key=, token="): ThingsboardClient( URL, "user@tb.io", "pass123", api_key="test-key", token="jwt.payload.sig" ) + mock_login.assert_not_called() + + def test_mutual_exclusion_message_says_what_to_do(self): + """The message names the fix, not only the collision.""" + with self.assertRaisesRegex( + ValueError, r"Pass exactly one of username=, api_key= or token=\." + ): + ThingsboardClient(URL, api_key="test-key", token="jwt.payload.sig") def test_password_without_username_rejected(self): """password= alone would be silently dropped, so it raises instead.""" @@ -185,6 +196,24 @@ def test_username_without_password_rejected(self): ThingsboardClient(URL, "user@tb.io") mock_login.assert_not_called() + def test_empty_api_key_rejected(self): + """api_key="" installs no header, so it raises rather than yielding a client + that silently sends no credentials — reachable via os.environ.get(..., "").""" + with self.assertRaisesRegex(ValueError, "api_key= must not be empty"): + ThingsboardClient(URL, api_key="") + + def test_empty_token_rejected(self): + """token="" has the same silent-no-header failure mode as api_key="".""" + with self.assertRaisesRegex(ValueError, "token= must not be empty"): + ThingsboardClient(URL, token="") + + def test_empty_username_rejected(self): + """username="" would reach /api/auth/login with an empty credential.""" + with patch(_LOGIN_PATCH_TARGET) as mock_login: + with self.assertRaisesRegex(ValueError, "username= must not be empty"): + ThingsboardClient(URL, "", "pass123") + mock_login.assert_not_called() + class TestThingsboardClientStructure(unittest.TestCase): """WRAP-02: ThingsboardClient has api_client and _auth_manager attributes.""" diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 58dc38b3..ad1d8a13 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -21,11 +21,8 @@ _REPO_ROOT = Path(__file__).parent.parent _COMMON_DIR = _REPO_ROOT / "common" -# The directory generate-client.sh overlays into /docs rather than into the -# package. Named once for the two source-side uses — the package check excludes it and -# the docs check reads from it. The destination happens to share the name, but the -# script hardcodes that separately (`cp "$common_docs_dir/"* "$module_dir/docs/"`), so -# renaming this constant would not rename the edition directories. +# generate-client.sh overlays common/docs into /docs rather than into the +# package, so the package check excludes it and the docs check reads from it. _DOCS_DIRNAME = "docs" # Excluded only where they sit at the top level of common/ — a nested file of the @@ -65,10 +62,7 @@ def _overlaid_doc_filenames(root: Path = _COMMON_DIR / _DOCS_DIRNAME) -> list[st passes no -r. A missing directory yields an empty list rather than raising, matching what rglob - does for _overlaid_filenames. Both then shrink to zero parametrized cases, and - test_discovery_finds_filenames_and_editions is the single place that reports it — - as a plain test failure, rather than a collection-time error that would take the - unrelated package-sync cases down with it. + does for _overlaid_filenames — see test_missing_directory_yields_empty_list. """ if not root.is_dir(): return [] @@ -102,8 +96,8 @@ def _assert_identical(source: Path, copy: Path, destinations: str) -> None: """ source_rel = source.relative_to(_REPO_ROOT).as_posix() copy_rel = copy.relative_to(_REPO_ROOT).as_posix() - # Action-first and with no trailing "after editing it": on the missing-copy branch - # the source is fine and re-running the script is the only step needed. + # Shared with the missing-copy branch, where the source is fine and re-running the + # script is the only step needed — hence the single action and no "after editing it". remediation = f"Run generate-client.sh (or copy {source_rel} into {destinations})." assert copy.is_file(), f"{copy_rel} is missing. {remediation}" @@ -156,8 +150,9 @@ def test_doc_walk_is_flat(tmp_path): def test_missing_directory_yields_empty_list(walk, tmp_path): """Both helpers degrade to [] rather than raising when their root is absent. - Parametrized over both so the parity _overlaid_doc_filenames claims in its - docstring is enforced rather than asserted. + An absent root is then reported by test_discovery_finds_filenames_and_editions as + a plain failure, instead of a collection-time error that would take the unrelated + package-sync cases down with it. """ assert walk(tmp_path / "missing") == [] diff --git a/tests/test_readme.py b/tests/test_readme.py index 60e0284e..6b29da43 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -17,7 +17,6 @@ import ast import re -from functools import cache from pathlib import Path import pytest @@ -35,18 +34,37 @@ def _label(path: Path) -> str: - """Repo-relative name for a document, used for both test ids and messages.""" - return path.relative_to(REPO_ROOT).as_posix() + """Repo-relative name for a document, used for both test ids and messages. + + Falls back to the full path for the tmp_path fixtures the helper tests use. + """ + if path.is_relative_to(REPO_ROOT): + return path.relative_to(REPO_ROOT).as_posix() + return str(path) -@cache def _read(path: Path) -> str: - """Read a document once per session — several tests read the same few files.""" return path.read_text(encoding="utf-8") -def _python_blocks(path: Path) -> list: - """Return the document's ```python blocks, asserting it has at least one. +def _has_heading(content: str, heading: str) -> bool: + """Whether content contains heading as a whole line of its own. + + Anchored and case-sensitive, so the match means what the caller spells: a + demotion to '### ...', a change of capitalization, or the heading appearing + mid-line all count as absent. A bare substring would survive deleting the very + section it is meant to guard, since these terms recur in prose and code samples. + """ + return re.search(rf"^{re.escape(heading)}[ \t]*$", content, re.MULTILINE) is not None + + +def _assert_heading(path: Path, heading: str) -> None: + """Assert the document contains heading as a whole line.""" + assert _has_heading(_read(path), heading), f"{_label(path)} missing '{heading}' section" + + +def _require_python_blocks(path: Path) -> list[str]: + """Return the document's ```python blocks, failing if it has none. The non-empty guard keeps a document that lost all its code samples from vacuously satisfying the rules applied to those samples. @@ -56,7 +74,7 @@ def _python_blocks(path: Path) -> list: return blocks -def _validate_python_syntax(blocks: list) -> list: +def _validate_python_syntax(blocks: list[str]) -> list: """Validate Python syntax for each block using ast.parse. Returns a list of (block_index, error_message) tuples for any blocks @@ -71,6 +89,34 @@ def _validate_python_syntax(blocks: list) -> list: return errors +# --------------------------------------------------------------------------- +# The heading matcher both documents' section checks rely on +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "line, expected", + ( + ("## JWT Login", True), + ("### JWT Login", False), # demoted + ("## jwt login", False), # recased + ("x ## JWT Login", False), # not at the start of a line + ("## JWT Login extra", False), # not the whole line + ), +) +def test_has_heading_is_anchored_and_case_sensitive(line, expected): + """Pin the matcher's strictness, which the section checks assert nothing about.""" + assert _has_heading(f"intro\n{line}\nbody\n", "## JWT Login") is expected + + +def test_require_python_blocks_fails_without_any(tmp_path): + """A document that lost all its ```python fences fails instead of passing vacuously.""" + doc = tmp_path / "no-blocks.md" + doc.write_text("# Title\n\nProse only.\n", encoding="utf-8") + with pytest.raises(AssertionError, match="no Python code blocks"): + _require_python_blocks(doc) + + # --------------------------------------------------------------------------- # DOC-01 / DOC-04: checks that apply to both documents # --------------------------------------------------------------------------- @@ -85,7 +131,7 @@ def test_document_exists(path): @pytest.mark.parametrize("path", DOCUMENTS, ids=_label) def test_document_code_blocks_valid_python(path): """All Python code blocks in the document are syntactically valid.""" - errors = _validate_python_syntax(_python_blocks(path)) + errors = _validate_python_syntax(_require_python_blocks(path)) assert not errors, f"{_label(path)} has Python code blocks with syntax errors:\n" + "\n".join( f" Block {i}: {msg}" for i, msg in errors ) @@ -94,7 +140,7 @@ def test_document_code_blocks_valid_python(path): @pytest.mark.parametrize("path", DOCUMENTS, ids=_label) def test_document_uses_keyword_constructor(path): """The document's Python code blocks use keyword argument form (username=...).""" - has_keyword_form = any("username=" in block for block in _python_blocks(path)) + has_keyword_form = any("username=" in block for block in _require_python_blocks(path)) assert has_keyword_form, ( f"{_label(path)} has no Python code block containing 'username=' " "(must use keyword argument form, not positional)" @@ -108,9 +154,9 @@ def test_document_uses_keyword_constructor(path): def test_readme_has_quickstart(): """README.md contains quickstart section with install, client, and error handling.""" - content = _read(README) + _assert_heading(README, "## Quickstart") - assert "## Quickstart" in content, "README.md missing '## Quickstart' section heading" + content = _read(README) assert "pip install" in content, "README.md missing 'pip install' instruction" assert "ThingsboardClient" in content, "README.md missing 'ThingsboardClient' class name" assert "ApiException" in content, "README.md missing 'ApiException' error handling" @@ -120,9 +166,6 @@ def test_readme_has_quickstart(): # DOC-04: common/docs/tb-examples.md only # --------------------------------------------------------------------------- -# Matched as whole headings rather than as bare words: these terms also occur in prose -# and in code samples elsewhere in the file, so a substring survives deleting the very -# section it is meant to guard. _REQUIRED_HEADINGS = ( "## JWT Login", "## API Key Login", @@ -137,11 +180,5 @@ def test_readme_has_quickstart(): @pytest.mark.parametrize("heading", _REQUIRED_HEADINGS) def test_tb_examples_required_sections(heading): - """common/docs/tb-examples.md contains each required section heading. - - Anchored and case-sensitive, so the assertion means what the tuple spells: a - demotion to '### ...' or a change of capitalization fails rather than passing - on a substring match. - """ - found = re.search(rf"^{re.escape(heading)}\s*$", _read(TB_EXAMPLES), re.MULTILINE) - assert found, f"{_label(TB_EXAMPLES)} missing '{heading}' section" + """common/docs/tb-examples.md contains each required section heading.""" + _assert_heading(TB_EXAMPLES, heading) From 3e8fb5df186442f837aa5b8ec30ae14103599148 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 4 Aug 2026 08:49:52 +0300 Subject: [PATCH 13/17] Address eleventh review: bound the auth round-trip, share the edition list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _raw_post now passes timeout=AUTH_REQUEST_TIMEOUT_S. urllib3 defaults to no socket timeout, and now that waiters block on an in-flight refresh, an unresponsive auth endpoint would park every API thread in the process rather than the one that triggered the refresh. Pinned by TestRawPostTimeout. - Editions now live in editions.txt, read by both generate-client.sh and test_common_overlay.py, so neither side's formatting is load-bearing for the other and a partial match can no longer go green. The script's read loop skips blanks and # comments, tolerates a missing trailing newline, and fails loudly on an empty list; the test parser mirrors it and is pinned by a fixture. - _refresh_if_needed uses Condition.wait_for() instead of spelling the predicate three times. - _validate_auth_args annotates its five parameters and is called with keywords — five interchangeable optional strings passed positionally made a transposition silent. The empty-argument message now carries a remedy like its companion. - README.md and tb-examples.md enumerated three raising conditions where there are now four; both document the empty-argument rule, with the three edition docs copies re-synced. - Parametrize the empty-string cases over all five arguments, covering password="" and refresh_token="" and pinning that the check runs ahead of the companion rules. Verified 6 tests fail when those checks are removed. - The concurrency test captures thread exceptions and re-raises them in the main thread, and asserts both threads finished, so a hang or a raise no longer reports itself as "did not wait for the refresh". --- README.md | 4 ++++ ce/docs/tb-examples.md | 4 ++++ ce/tb_ce_client/_auth.py | 12 ++++++++-- ce/tb_ce_client/client.py | 22 +++++++++++++++--- common/_auth.py | 12 ++++++++-- common/client.py | 22 +++++++++++++++--- common/docs/tb-examples.md | 4 ++++ editions.txt | 3 +++ generate-client.sh | 15 +++++++++--- paas/docs/tb-examples.md | 4 ++++ paas/tb_paas_client/_auth.py | 12 ++++++++-- paas/tb_paas_client/client.py | 22 +++++++++++++++--- pe/docs/tb-examples.md | 4 ++++ pe/tb_pe_client/_auth.py | 12 ++++++++-- pe/tb_pe_client/client.py | 22 +++++++++++++++--- tests/test_auth.py | 38 +++++++++++++++++++++++++++++-- tests/test_client.py | 43 ++++++++++++++++++++++------------- tests/test_common_overlay.py | 34 ++++++++++++++++++--------- 18 files changed, 237 insertions(+), 52 deletions(-) create mode 100644 editions.txt diff --git a/README.md b/README.md index bc30f22e..773f52f4 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ The three authenticated modes are mutually exclusive — passing more than one r `refresh_token=` without `token=`. `token=` on its own is valid; it simply means the token is never refreshed. +An empty string is also rejected, so `api_key=os.environ.get("TB_API_KEY", "")` raises +rather than building a client that silently sends no credentials. Omit the auth +arguments entirely for an unauthenticated client. + ## Resource cleanup Use the client as a context manager so `close()` is called automatically on exit: diff --git a/ce/docs/tb-examples.md b/ce/docs/tb-examples.md index cec1a7ed..c1dafa05 100644 --- a/ce/docs/tb-examples.md +++ b/ce/docs/tb-examples.md @@ -53,6 +53,10 @@ raises `ValueError`, as does passing `username=` without `password=` or vice ver `refresh_token=` without `token=`. `token=` on its own is valid; it simply means the token is never refreshed. +An empty string is also rejected, so `api_key=os.environ.get("TB_API_KEY", "")` raises +rather than building a client that silently sends no credentials. Omit the auth +arguments entirely for an unauthenticated client. + ## Context Manager ```python diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 7b5ddb6a..0beeb9b2 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -34,6 +34,11 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 +# Socket timeout for the raw auth calls. urllib3 defaults to no timeout at all, and +# every API thread now blocks behind an in-flight refresh, so an unresponsive auth +# endpoint would otherwise hang the whole process rather than one thread. +AUTH_REQUEST_TIMEOUT_S = 30.0 + # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a # single owner instead of literals scattered across client.py and _auth.py. @@ -203,8 +208,7 @@ def _refresh_if_needed(self) -> None: # Another thread is already refreshing. Block rather than return: # returning here would send the expired token that thread is busy # replacing, and nothing retries the resulting 401. - while self._refreshing: - self._refresh_state.wait() + self._refresh_state.wait_for(lambda: not self._refreshing) # Its outcome is ours. Refreshing again on failure would multiply one # failed round-trip by however many threads were waiting. return @@ -260,6 +264,9 @@ def _raw_post(self, path: str, body: bytes) -> dict: if we used ApiClient here, the hook would fire again while already inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). + + Bounded by AUTH_REQUEST_TIMEOUT_S: this call is on the critical path for + every thread waiting on a refresh, so it must not be able to hang forever. """ http = urllib3.PoolManager() response = http.request( @@ -267,6 +274,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: self._base_url + path, body=body, headers={"Content-Type": "application/json"}, + timeout=AUTH_REQUEST_TIMEOUT_S, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index 5a3d9187..f4133645 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -35,7 +35,13 @@ from .models.login_request import LoginRequest -def _validate_auth_args(username, password, api_key, token, refresh_token) -> None: +def _validate_auth_args( + username: "str | None", + password: "str | None", + api_key: "str | None", + token: "str | None", + refresh_token: "str | None", +) -> None: """Reject auth argument combinations that cannot be honoured. Raises ValueError describing the offending arguments; returns None otherwise. @@ -43,6 +49,7 @@ def _validate_auth_args(username, password, api_key, token, refresh_token) -> No # An empty string passes every "is not None" check below but installs no header, # so the client would silently send no credentials at all — the failure mode this # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). + # Checked first so that reaches the caller instead of a downstream collision. for name, value in ( ("username", username), ("password", password), @@ -51,7 +58,10 @@ def _validate_auth_args(username, password, api_key, token, refresh_token) -> No ("refresh_token", refresh_token), ): if value is not None and not value: - raise ValueError(f"{name}= must not be empty") + raise ValueError( + f"{name}= must not be empty; pass a value, or omit all auth " + "arguments for an unauthenticated client." + ) # The three auth modes share a single X-Authorization slot, so combining them is # ambiguous: whichever ran last would win, and under api_key auth the refresh hook @@ -148,7 +158,13 @@ def __init__( # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} - _validate_auth_args(username, password, api_key, token, refresh_token) + _validate_auth_args( + username=username, + password=password, + api_key=api_key, + token=token, + refresh_token=refresh_token, + ) configuration = Configuration(host=url) diff --git a/common/_auth.py b/common/_auth.py index 7b5ddb6a..0beeb9b2 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -34,6 +34,11 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 +# Socket timeout for the raw auth calls. urllib3 defaults to no timeout at all, and +# every API thread now blocks behind an in-flight refresh, so an unresponsive auth +# endpoint would otherwise hang the whole process rather than one thread. +AUTH_REQUEST_TIMEOUT_S = 30.0 + # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a # single owner instead of literals scattered across client.py and _auth.py. @@ -203,8 +208,7 @@ def _refresh_if_needed(self) -> None: # Another thread is already refreshing. Block rather than return: # returning here would send the expired token that thread is busy # replacing, and nothing retries the resulting 401. - while self._refreshing: - self._refresh_state.wait() + self._refresh_state.wait_for(lambda: not self._refreshing) # Its outcome is ours. Refreshing again on failure would multiply one # failed round-trip by however many threads were waiting. return @@ -260,6 +264,9 @@ def _raw_post(self, path: str, body: bytes) -> dict: if we used ApiClient here, the hook would fire again while already inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). + + Bounded by AUTH_REQUEST_TIMEOUT_S: this call is on the critical path for + every thread waiting on a refresh, so it must not be able to hang forever. """ http = urllib3.PoolManager() response = http.request( @@ -267,6 +274,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: self._base_url + path, body=body, headers={"Content-Type": "application/json"}, + timeout=AUTH_REQUEST_TIMEOUT_S, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/common/client.py b/common/client.py index 5a3d9187..f4133645 100644 --- a/common/client.py +++ b/common/client.py @@ -35,7 +35,13 @@ from .models.login_request import LoginRequest -def _validate_auth_args(username, password, api_key, token, refresh_token) -> None: +def _validate_auth_args( + username: "str | None", + password: "str | None", + api_key: "str | None", + token: "str | None", + refresh_token: "str | None", +) -> None: """Reject auth argument combinations that cannot be honoured. Raises ValueError describing the offending arguments; returns None otherwise. @@ -43,6 +49,7 @@ def _validate_auth_args(username, password, api_key, token, refresh_token) -> No # An empty string passes every "is not None" check below but installs no header, # so the client would silently send no credentials at all — the failure mode this # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). + # Checked first so that reaches the caller instead of a downstream collision. for name, value in ( ("username", username), ("password", password), @@ -51,7 +58,10 @@ def _validate_auth_args(username, password, api_key, token, refresh_token) -> No ("refresh_token", refresh_token), ): if value is not None and not value: - raise ValueError(f"{name}= must not be empty") + raise ValueError( + f"{name}= must not be empty; pass a value, or omit all auth " + "arguments for an unauthenticated client." + ) # The three auth modes share a single X-Authorization slot, so combining them is # ambiguous: whichever ran last would win, and under api_key auth the refresh hook @@ -148,7 +158,13 @@ def __init__( # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} - _validate_auth_args(username, password, api_key, token, refresh_token) + _validate_auth_args( + username=username, + password=password, + api_key=api_key, + token=token, + refresh_token=refresh_token, + ) configuration = Configuration(host=url) diff --git a/common/docs/tb-examples.md b/common/docs/tb-examples.md index cec1a7ed..c1dafa05 100644 --- a/common/docs/tb-examples.md +++ b/common/docs/tb-examples.md @@ -53,6 +53,10 @@ raises `ValueError`, as does passing `username=` without `password=` or vice ver `refresh_token=` without `token=`. `token=` on its own is valid; it simply means the token is never refreshed. +An empty string is also rejected, so `api_key=os.environ.get("TB_API_KEY", "")` raises +rather than building a client that silently sends no credentials. Omit the auth +arguments entirely for an unauthenticated client. + ## Context Manager ```python diff --git a/editions.txt b/editions.txt new file mode 100644 index 00000000..12342af7 --- /dev/null +++ b/editions.txt @@ -0,0 +1,3 @@ +ce +pe +paas diff --git a/generate-client.sh b/generate-client.sh index 0b0a554e..4a82d2c0 100755 --- a/generate-client.sh +++ b/generate-client.sh @@ -61,9 +61,18 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# tests/test_common_overlay.py parses this array to know which editions to check, -# so keep it on one line at column 0 with double-quoted entries. -EDITIONS=("ce" "pe" "paas") +# The edition list lives in editions.txt so this script and the tests that check its +# output read the same source. One name per line; blank lines and # comments ignored. +EDITIONS=() +# `|| [ -n "$line" ]` so a final line with no trailing newline is not dropped. +while read -r line || [ -n "$line" ]; do + line="$(echo "$line" | tr -d '[:space:]')" + case "$line" in ''|'#'*) continue ;; esac + EDITIONS+=("$line") +done < "$SCRIPT_DIR/editions.txt" +if [ ${#EDITIONS[@]} -eq 0 ]; then + echo "Error: no editions listed in $SCRIPT_DIR/editions.txt"; exit 1 +fi VERBOSE=false DRY_RUN=false diff --git a/paas/docs/tb-examples.md b/paas/docs/tb-examples.md index cec1a7ed..c1dafa05 100644 --- a/paas/docs/tb-examples.md +++ b/paas/docs/tb-examples.md @@ -53,6 +53,10 @@ raises `ValueError`, as does passing `username=` without `password=` or vice ver `refresh_token=` without `token=`. `token=` on its own is valid; it simply means the token is never refreshed. +An empty string is also rejected, so `api_key=os.environ.get("TB_API_KEY", "")` raises +rather than building a client that silently sends no credentials. Omit the auth +arguments entirely for an unauthenticated client. + ## Context Manager ```python diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 7b5ddb6a..0beeb9b2 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -34,6 +34,11 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 +# Socket timeout for the raw auth calls. urllib3 defaults to no timeout at all, and +# every API thread now blocks behind an in-flight refresh, so an unresponsive auth +# endpoint would otherwise hang the whole process rather than one thread. +AUTH_REQUEST_TIMEOUT_S = 30.0 + # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a # single owner instead of literals scattered across client.py and _auth.py. @@ -203,8 +208,7 @@ def _refresh_if_needed(self) -> None: # Another thread is already refreshing. Block rather than return: # returning here would send the expired token that thread is busy # replacing, and nothing retries the resulting 401. - while self._refreshing: - self._refresh_state.wait() + self._refresh_state.wait_for(lambda: not self._refreshing) # Its outcome is ours. Refreshing again on failure would multiply one # failed round-trip by however many threads were waiting. return @@ -260,6 +264,9 @@ def _raw_post(self, path: str, body: bytes) -> dict: if we used ApiClient here, the hook would fire again while already inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). + + Bounded by AUTH_REQUEST_TIMEOUT_S: this call is on the critical path for + every thread waiting on a refresh, so it must not be able to hang forever. """ http = urllib3.PoolManager() response = http.request( @@ -267,6 +274,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: self._base_url + path, body=body, headers={"Content-Type": "application/json"}, + timeout=AUTH_REQUEST_TIMEOUT_S, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index 5a3d9187..f4133645 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -35,7 +35,13 @@ from .models.login_request import LoginRequest -def _validate_auth_args(username, password, api_key, token, refresh_token) -> None: +def _validate_auth_args( + username: "str | None", + password: "str | None", + api_key: "str | None", + token: "str | None", + refresh_token: "str | None", +) -> None: """Reject auth argument combinations that cannot be honoured. Raises ValueError describing the offending arguments; returns None otherwise. @@ -43,6 +49,7 @@ def _validate_auth_args(username, password, api_key, token, refresh_token) -> No # An empty string passes every "is not None" check below but installs no header, # so the client would silently send no credentials at all — the failure mode this # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). + # Checked first so that reaches the caller instead of a downstream collision. for name, value in ( ("username", username), ("password", password), @@ -51,7 +58,10 @@ def _validate_auth_args(username, password, api_key, token, refresh_token) -> No ("refresh_token", refresh_token), ): if value is not None and not value: - raise ValueError(f"{name}= must not be empty") + raise ValueError( + f"{name}= must not be empty; pass a value, or omit all auth " + "arguments for an unauthenticated client." + ) # The three auth modes share a single X-Authorization slot, so combining them is # ambiguous: whichever ran last would win, and under api_key auth the refresh hook @@ -148,7 +158,13 @@ def __init__( # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} - _validate_auth_args(username, password, api_key, token, refresh_token) + _validate_auth_args( + username=username, + password=password, + api_key=api_key, + token=token, + refresh_token=refresh_token, + ) configuration = Configuration(host=url) diff --git a/pe/docs/tb-examples.md b/pe/docs/tb-examples.md index cec1a7ed..c1dafa05 100644 --- a/pe/docs/tb-examples.md +++ b/pe/docs/tb-examples.md @@ -53,6 +53,10 @@ raises `ValueError`, as does passing `username=` without `password=` or vice ver `refresh_token=` without `token=`. `token=` on its own is valid; it simply means the token is never refreshed. +An empty string is also rejected, so `api_key=os.environ.get("TB_API_KEY", "")` raises +rather than building a client that silently sends no credentials. Omit the auth +arguments entirely for an unauthenticated client. + ## Context Manager ```python diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 7b5ddb6a..0beeb9b2 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -34,6 +34,11 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 +# Socket timeout for the raw auth calls. urllib3 defaults to no timeout at all, and +# every API thread now blocks behind an in-flight refresh, so an unresponsive auth +# endpoint would otherwise hang the whole process rather than one thread. +AUTH_REQUEST_TIMEOUT_S = 30.0 + # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a # single owner instead of literals scattered across client.py and _auth.py. @@ -203,8 +208,7 @@ def _refresh_if_needed(self) -> None: # Another thread is already refreshing. Block rather than return: # returning here would send the expired token that thread is busy # replacing, and nothing retries the resulting 401. - while self._refreshing: - self._refresh_state.wait() + self._refresh_state.wait_for(lambda: not self._refreshing) # Its outcome is ours. Refreshing again on failure would multiply one # failed round-trip by however many threads were waiting. return @@ -260,6 +264,9 @@ def _raw_post(self, path: str, body: bytes) -> dict: if we used ApiClient here, the hook would fire again while already inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). + + Bounded by AUTH_REQUEST_TIMEOUT_S: this call is on the critical path for + every thread waiting on a refresh, so it must not be able to hang forever. """ http = urllib3.PoolManager() response = http.request( @@ -267,6 +274,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: self._base_url + path, body=body, headers={"Content-Type": "application/json"}, + timeout=AUTH_REQUEST_TIMEOUT_S, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index 5a3d9187..f4133645 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -35,7 +35,13 @@ from .models.login_request import LoginRequest -def _validate_auth_args(username, password, api_key, token, refresh_token) -> None: +def _validate_auth_args( + username: "str | None", + password: "str | None", + api_key: "str | None", + token: "str | None", + refresh_token: "str | None", +) -> None: """Reject auth argument combinations that cannot be honoured. Raises ValueError describing the offending arguments; returns None otherwise. @@ -43,6 +49,7 @@ def _validate_auth_args(username, password, api_key, token, refresh_token) -> No # An empty string passes every "is not None" check below but installs no header, # so the client would silently send no credentials at all — the failure mode this # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). + # Checked first so that reaches the caller instead of a downstream collision. for name, value in ( ("username", username), ("password", password), @@ -51,7 +58,10 @@ def _validate_auth_args(username, password, api_key, token, refresh_token) -> No ("refresh_token", refresh_token), ): if value is not None and not value: - raise ValueError(f"{name}= must not be empty") + raise ValueError( + f"{name}= must not be empty; pass a value, or omit all auth " + "arguments for an unauthenticated client." + ) # The three auth modes share a single X-Authorization slot, so combining them is # ambiguous: whichever ran last would win, and under api_key auth the refresh hook @@ -148,7 +158,13 @@ def __init__( # if __init__ raises partway through (before self.api_client is set). self._controllers: dict = {} - _validate_auth_args(username, password, api_key, token, refresh_token) + _validate_auth_args( + username=username, + password=password, + api_key=api_key, + token=token, + refresh_token=refresh_token, + ) configuration = Configuration(host=url) diff --git a/tests/test_auth.py b/tests/test_auth.py index e257af58..647a165d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -218,6 +218,27 @@ def side_effect(path, body): # --------------------------------------------------------------------------- +class TestRawPostTimeout(unittest.TestCase): + def test_raw_post_bounds_the_request(self): + """_raw_post passes a timeout — urllib3 defaults to none. + + Every API thread blocks behind an in-flight refresh, so an unresponsive auth + endpoint without this would hang the process rather than a single thread. + """ + auth = _AuthManager("http://tb:9090") + response = MagicMock() + response.status = 200 + response.data = b'{"token": "t", "refreshToken": "r"}' + + with patch("common._auth.urllib3.PoolManager") as mock_pool: + mock_pool.return_value.request.return_value = response + auth._raw_post("/api/auth/login", b"{}") + + timeout = mock_pool.return_value.request.call_args.kwargs.get("timeout") + self.assertIsNotNone(timeout, "_raw_post sent no timeout") + self.assertGreater(timeout, 0) + + class TestConcurrentRefresh(unittest.TestCase): def test_second_thread_waits_for_in_flight_refresh(self): """A thread arriving mid-refresh blocks, then sends the refreshed token. @@ -246,10 +267,17 @@ def blocking_post(path, _body): return {"token": new_token, "refreshToken": make_refresh_token(exp_offset_s=172800)} configs = {} + errors = [] def run_hook(name): - configs[name] = _mock_configuration() - auth.hook(configs[name]) + # An exception in a thread target only reaches stderr, which would surface + # here as a misleading "did not wait" — collect it and re-raise in the main + # thread instead. + try: + configs[name] = _mock_configuration() + auth.hook(configs[name]) + except BaseException as exc: # noqa: BLE001 - re-raised below + errors.append(exc) with patch.object(auth, "_raw_post", side_effect=blocking_post): first = threading.Thread(target=run_hook, args=("first",)) @@ -259,12 +287,18 @@ def run_hook(name): second = threading.Thread(target=run_hook, args=("second",)) second.start() second.join(timeout=0.2) + if errors: + raise errors[0] self.assertTrue(second.is_alive(), "second thread did not wait for the refresh") release_refresh.set() first.join(timeout=5) second.join(timeout=5) + if errors: + raise errors[0] + self.assertFalse(first.is_alive(), "refreshing thread never finished") + self.assertFalse(second.is_alive(), "waiting thread was never released") # One round-trip, not one per waiting thread, and both threads send its result. self.assertEqual(posts, ["/api/auth/token"]) self.assertEqual(configs["first"].api_key["ApiKeyForm"], new_token) diff --git a/tests/test_client.py b/tests/test_client.py index 7564cc5c..6efdc045 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -196,23 +196,34 @@ def test_username_without_password_rejected(self): ThingsboardClient(URL, "user@tb.io") mock_login.assert_not_called() - def test_empty_api_key_rejected(self): - """api_key="" installs no header, so it raises rather than yielding a client - that silently sends no credentials — reachable via os.environ.get(..., "").""" - with self.assertRaisesRegex(ValueError, "api_key= must not be empty"): - ThingsboardClient(URL, api_key="") - - def test_empty_token_rejected(self): - """token="" has the same silent-no-header failure mode as api_key="".""" - with self.assertRaisesRegex(ValueError, "token= must not be empty"): - ThingsboardClient(URL, token="") + def test_empty_auth_arguments_rejected(self): + """Every auth argument rejects "", which would otherwise install no header. - def test_empty_username_rejected(self): - """username="" would reach /api/auth/login with an empty credential.""" - with patch(_LOGIN_PATCH_TARGET) as mock_login: - with self.assertRaisesRegex(ValueError, "username= must not be empty"): - ThingsboardClient(URL, "", "pass123") - mock_login.assert_not_called() + The empty check runs ahead of the companion rules, so username="" reports the + empty argument rather than falling through to "username= requires password=". + Covering all five means reordering the checks fails here instead of silently + changing which message a caller sees. + """ + cases = ( + ("username", {"username": "", "password": "pass123"}), + ("password", {"username": "user@tb.io", "password": ""}), + ("api_key", {"api_key": ""}), + ("token", {"token": ""}), + ("refresh_token", {"token": "jwt.payload.sig", "refresh_token": ""}), + ) + for name, kwargs in cases: + with self.subTest(argument=name): + with patch(_LOGIN_PATCH_TARGET) as mock_login: + with self.assertRaisesRegex(ValueError, f"{name}= must not be empty"): + ThingsboardClient(URL, **kwargs) + mock_login.assert_not_called() + + def test_empty_argument_message_says_what_to_do(self): + """The empty-argument message carries a remedy, like the mutual-exclusion one.""" + with self.assertRaisesRegex( + ValueError, r"pass a value, or omit all auth arguments for an unauthenticated" + ): + ThingsboardClient(URL, api_key="") class TestThingsboardClientStructure(unittest.TestCase): diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index ad1d8a13..02048190 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -7,19 +7,20 @@ ship stale code — or stale documentation — to the rest. Every list is taken from the thing that defines it rather than hardcoded here: -filenames from common/ itself, editions from generate-client.sh. Adding either a file -or an edition extends the check with no test edit — and, because the editions come -from the script rather than from whichever directories happen to exist, an edition -whose package directory is missing fails instead of quietly dropping out. +filenames from common/ itself, editions from editions.txt — the same file +generate-client.sh reads. Adding either a file or an edition extends the check with no +test edit — and, because the editions come from that shared list rather than from +whichever directories happen to exist, an edition whose package directory is missing +fails instead of quietly dropping out. """ -import re from pathlib import Path import pytest _REPO_ROOT = Path(__file__).parent.parent _COMMON_DIR = _REPO_ROOT / "common" +_EDITIONS_FILE = _REPO_ROOT / "editions.txt" # generate-client.sh overlays common/docs into /docs rather than into the # package, so the package check excludes it and the docs check reads from it. @@ -69,12 +70,15 @@ def _overlaid_doc_filenames(root: Path = _COMMON_DIR / _DOCS_DIRNAME) -> list[st return sorted(p.name for p in root.iterdir() if p.is_file()) -def _editions() -> list[str]: - """Edition names parsed from the EDITIONS array in generate-client.sh.""" - script = (_REPO_ROOT / "generate-client.sh").read_text(encoding="utf-8") - match = re.search(r"^EDITIONS=\(([^)]*)\)", script, re.MULTILINE) - assert match, "could not find the EDITIONS=(...) array in generate-client.sh" - return sorted(re.findall(r'"([^"]+)"', match.group(1))) +def _editions(path: Path = _EDITIONS_FILE) -> list[str]: + """Edition names read from editions.txt — the same file generate-client.sh reads. + + One name per line; blank lines and # comments ignored, matching the read loop in + generate-client.sh. Reading the list rather than parsing it out of the shell script + means neither side's formatting is load-bearing for the other. + """ + lines = path.read_text(encoding="utf-8").splitlines() + return sorted(s for line in lines if (s := line.strip()) and not s.startswith("#")) def test_discovery_finds_filenames_and_editions(): @@ -133,6 +137,14 @@ def test_walk_exclusion_semantics(tmp_path): ] +def test_editions_parsing_matches_the_script(tmp_path): + """Blank lines and # comments are ignored, mirroring the script's grep.""" + listing = tmp_path / "editions.txt" + listing.write_text("# a comment\n\nce\n pe \n\n# paas is not shipped yet\npaas\n") + + assert _editions(listing) == ["ce", "paas", "pe"] + + def test_doc_walk_is_flat(tmp_path): """The docs helper takes top-level files only, matching `cp common/docs/*`. From 835fef079b8638fb46a297882dee36dc01880a03 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 4 Aug 2026 09:23:38 +0300 Subject: [PATCH 14/17] Address twelfth review: make the auth timeout a real ceiling, unify edition parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AUTH_REQUEST_TIMEOUT_S was not the bound its name claimed. PoolManager applies Retry.DEFAULT (total=3) and the connection-error branch of Retry.increment never consults allowed_methods, so this POST retried too; a bare float also expands to Timeout(connect, read) with total unbounded. Verified against the installed urllib3 2.6.3, matching the report against 1.26.5. Now Timeout(total=...) with retries=False, so one blackholed auth host costs the advertised ceiling once rather than 4x it twice via the _do_login fallback. - Make it tunable as auth_timeout_ms, alongside the existing retry knobs and in the same integer-milliseconds unit, instead of a float constant callers monkeypatch. - generate-client.sh and the Python mirror disagreed on the same editions.txt: `tr -d [:space:]` strips whitespace anywhere, str.strip() only at the ends, so `pa as` parsed as two different lists. The script now relies on `read -r`'s own IFS trimming, which is exactly str.strip() — and drops two subprocesses per line. - test_editions_parsing_matches_the_script now runs the script via a new --list-editions mode against the same fixture instead of restating its rules, so the two implementations are compared rather than one being pinned twice. Verified it fails when `tr -d` is restored. - scripts/build-packages.sh consumes --list-editions rather than carrying its own EDITIONS array; the usage text derives from the list; the comment no longer claims authority over the per-edition thresholds it does not govern. - Count editions in a scalar rather than ${#EDITIONS[@]}, which is unbound under set -u on bash < 4.4 when the array is empty. - _editions() returns [] for a missing file, like the two walk helpers — it runs at collection time, so raising took the unrelated package-sync cases down with it. - Give the three companion validation messages the same remedy clause as the other two, and fix a comment sentence that was missing its subject. - Pin the timeout against DEFAULT_AUTH_TIMEOUT_MS and retries=False, rather than "some positive number". --- ce/tb_ce_client/_auth.py | 33 ++++++++++++++++----- ce/tb_ce_client/client.py | 20 +++++++++---- common/_auth.py | 33 ++++++++++++++++----- common/client.py | 20 +++++++++---- generate-client.sh | 26 ++++++++++++----- paas/tb_paas_client/_auth.py | 33 ++++++++++++++++----- paas/tb_paas_client/client.py | 20 +++++++++---- pe/tb_pe_client/_auth.py | 33 ++++++++++++++++----- pe/tb_pe_client/client.py | 20 +++++++++---- scripts/build-packages.sh | 7 ++++- tests/test_auth.py | 54 ++++++++++++++++++++++++----------- tests/test_common_overlay.py | 44 +++++++++++++++++++++++++--- 12 files changed, 263 insertions(+), 80 deletions(-) diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 0beeb9b2..0e667983 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -34,10 +34,15 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 -# Socket timeout for the raw auth calls. urllib3 defaults to no timeout at all, and -# every API thread now blocks behind an in-flight refresh, so an unresponsive auth +# Wall-clock ceiling for a single raw auth call. urllib3 defaults to no timeout at all, +# and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. -AUTH_REQUEST_TIMEOUT_S = 30.0 +# +# Applied as Timeout(total=...) with retries disabled, so it is the real ceiling: a bare +# float sets connect and read separately and leaves total unbounded, and PoolManager +# otherwise applies Retry.DEFAULT (total=3) — whose connection-error branch does not +# consult allowed_methods, so even POST would retry and cost 4x this value. +DEFAULT_AUTH_TIMEOUT_MS = 30_000 # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a @@ -121,14 +126,24 @@ class _AuthManager: The auth mode is decided once in __init__ and never re-derived per request. """ - def __init__(self, base_url: str, api_key: "str | None" = None): + def __init__( + self, + base_url: str, + api_key: "str | None" = None, + auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS, + ): """ Args: base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). Trailing slashes are stripped. api_key: The API key string for API key auth, or None for JWT auth (username/password or an externally supplied token). + auth_timeout_ms: Ceiling for a single /api/auth call, in milliseconds. + Bounds how long every other thread can be blocked behind a + refresh, so it is a knob a slow on-prem server or a + latency-sensitive caller will want to change. """ + self._auth_timeout_s = auth_timeout_ms / 1000 self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX @@ -265,8 +280,11 @@ def _raw_post(self, path: str, body: bytes) -> dict: inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). - Bounded by AUTH_REQUEST_TIMEOUT_S: this call is on the critical path for - every thread waiting on a refresh, so it must not be able to hang forever. + Bounded by auth_timeout_ms: this call is on the critical path for every thread + waiting on a refresh, so it must not be able to hang forever. retries=False + because urllib3 would otherwise retry this POST on connection errors — see + DEFAULT_AUTH_TIMEOUT_MS — and auth POSTs are not idempotent anyway; a failed + refresh already falls back to _do_login. """ http = urllib3.PoolManager() response = http.request( @@ -274,7 +292,8 @@ def _raw_post(self, path: str, body: bytes) -> dict: self._base_url + path, body=body, headers={"Content-Type": "application/json"}, - timeout=AUTH_REQUEST_TIMEOUT_S, + timeout=urllib3.Timeout(total=self._auth_timeout_s), + retries=False, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index f4133645..8da07543 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -27,7 +27,7 @@ import importlib -from ._auth import _AuthManager +from ._auth import DEFAULT_AUTH_TIMEOUT_MS, _AuthManager from ._controller_map import _CONTROLLER_ATTR_MAP, _CONTROLLER_MAP from ._retry import _RetryingRESTClient from .api_client import ApiClient @@ -49,7 +49,8 @@ def _validate_auth_args( # An empty string passes every "is not None" check below but installs no header, # so the client would silently send no credentials at all — the failure mode this # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). - # Checked first so that reaches the caller instead of a downstream collision. + # Checked before the rules below so an empty value reports itself rather than the + # companion-argument error it would also trip. for name, value in ( ("username", username), ("password", password), @@ -83,12 +84,16 @@ def _validate_auth_args( # left to LoginRequest, whose password is a required StrictStr — otherwise the # caller gets a pydantic ValidationError from inside the generated model. if password is not None and username is None: - raise ValueError("password= requires username=") + raise ValueError( + "password= requires username=; pass both, or omit both for an unauthenticated client." + ) if username is not None and password is None: - raise ValueError("username= requires password=") + raise ValueError( + "username= requires password=; pass both, or omit both for an unauthenticated client." + ) # refresh_token= is likewise read only by the token branch. if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=") + raise ValueError("refresh_token= requires token=; pass token=, or omit both.") class ThingsboardClient: @@ -131,6 +136,7 @@ def __init__( initial_retry_delay_ms: int = 1_000, max_retry_delay_ms: int = 30_000, retry_on_rate_limit: bool = True, + auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS, ): """Construct ThingsboardClient and authenticate. @@ -147,6 +153,8 @@ def __init__( max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). retry_on_rate_limit: If True (default), wraps rest_client with _RetryingRESTClient. If False, uses plain RESTClientObject. + auth_timeout_ms: Ceiling for a single /api/auth call (default 30000). + Bounds how long other threads block behind a token refresh. Raises: ValueError: If more than one of username=, api_key= or token= is given; @@ -168,7 +176,7 @@ def __init__( configuration = Configuration(host=url) - auth_manager = _AuthManager(url, api_key) + auth_manager = _AuthManager(url, api_key, auth_timeout_ms) # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook diff --git a/common/_auth.py b/common/_auth.py index 0beeb9b2..0e667983 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -34,10 +34,15 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 -# Socket timeout for the raw auth calls. urllib3 defaults to no timeout at all, and -# every API thread now blocks behind an in-flight refresh, so an unresponsive auth +# Wall-clock ceiling for a single raw auth call. urllib3 defaults to no timeout at all, +# and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. -AUTH_REQUEST_TIMEOUT_S = 30.0 +# +# Applied as Timeout(total=...) with retries disabled, so it is the real ceiling: a bare +# float sets connect and read separately and leaves total unbounded, and PoolManager +# otherwise applies Retry.DEFAULT (total=3) — whose connection-error branch does not +# consult allowed_methods, so even POST would retry and cost 4x this value. +DEFAULT_AUTH_TIMEOUT_MS = 30_000 # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a @@ -121,14 +126,24 @@ class _AuthManager: The auth mode is decided once in __init__ and never re-derived per request. """ - def __init__(self, base_url: str, api_key: "str | None" = None): + def __init__( + self, + base_url: str, + api_key: "str | None" = None, + auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS, + ): """ Args: base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). Trailing slashes are stripped. api_key: The API key string for API key auth, or None for JWT auth (username/password or an externally supplied token). + auth_timeout_ms: Ceiling for a single /api/auth call, in milliseconds. + Bounds how long every other thread can be blocked behind a + refresh, so it is a knob a slow on-prem server or a + latency-sensitive caller will want to change. """ + self._auth_timeout_s = auth_timeout_ms / 1000 self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX @@ -265,8 +280,11 @@ def _raw_post(self, path: str, body: bytes) -> dict: inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). - Bounded by AUTH_REQUEST_TIMEOUT_S: this call is on the critical path for - every thread waiting on a refresh, so it must not be able to hang forever. + Bounded by auth_timeout_ms: this call is on the critical path for every thread + waiting on a refresh, so it must not be able to hang forever. retries=False + because urllib3 would otherwise retry this POST on connection errors — see + DEFAULT_AUTH_TIMEOUT_MS — and auth POSTs are not idempotent anyway; a failed + refresh already falls back to _do_login. """ http = urllib3.PoolManager() response = http.request( @@ -274,7 +292,8 @@ def _raw_post(self, path: str, body: bytes) -> dict: self._base_url + path, body=body, headers={"Content-Type": "application/json"}, - timeout=AUTH_REQUEST_TIMEOUT_S, + timeout=urllib3.Timeout(total=self._auth_timeout_s), + retries=False, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/common/client.py b/common/client.py index f4133645..8da07543 100644 --- a/common/client.py +++ b/common/client.py @@ -27,7 +27,7 @@ import importlib -from ._auth import _AuthManager +from ._auth import DEFAULT_AUTH_TIMEOUT_MS, _AuthManager from ._controller_map import _CONTROLLER_ATTR_MAP, _CONTROLLER_MAP from ._retry import _RetryingRESTClient from .api_client import ApiClient @@ -49,7 +49,8 @@ def _validate_auth_args( # An empty string passes every "is not None" check below but installs no header, # so the client would silently send no credentials at all — the failure mode this # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). - # Checked first so that reaches the caller instead of a downstream collision. + # Checked before the rules below so an empty value reports itself rather than the + # companion-argument error it would also trip. for name, value in ( ("username", username), ("password", password), @@ -83,12 +84,16 @@ def _validate_auth_args( # left to LoginRequest, whose password is a required StrictStr — otherwise the # caller gets a pydantic ValidationError from inside the generated model. if password is not None and username is None: - raise ValueError("password= requires username=") + raise ValueError( + "password= requires username=; pass both, or omit both for an unauthenticated client." + ) if username is not None and password is None: - raise ValueError("username= requires password=") + raise ValueError( + "username= requires password=; pass both, or omit both for an unauthenticated client." + ) # refresh_token= is likewise read only by the token branch. if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=") + raise ValueError("refresh_token= requires token=; pass token=, or omit both.") class ThingsboardClient: @@ -131,6 +136,7 @@ def __init__( initial_retry_delay_ms: int = 1_000, max_retry_delay_ms: int = 30_000, retry_on_rate_limit: bool = True, + auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS, ): """Construct ThingsboardClient and authenticate. @@ -147,6 +153,8 @@ def __init__( max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). retry_on_rate_limit: If True (default), wraps rest_client with _RetryingRESTClient. If False, uses plain RESTClientObject. + auth_timeout_ms: Ceiling for a single /api/auth call (default 30000). + Bounds how long other threads block behind a token refresh. Raises: ValueError: If more than one of username=, api_key= or token= is given; @@ -168,7 +176,7 @@ def __init__( configuration = Configuration(host=url) - auth_manager = _AuthManager(url, api_key) + auth_manager = _AuthManager(url, api_key, auth_timeout_ms) # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook diff --git a/generate-client.sh b/generate-client.sh index 4a82d2c0..cc372794 100755 --- a/generate-client.sh +++ b/generate-client.sh @@ -22,7 +22,7 @@ # ./generate-client.sh [options] [base-url] # # Arguments: -# edition ce | pe | paas | all +# edition one of the names in editions.txt, or "all" # base-url Optional. Fetches spec from /v3/api-docs/thingsboard # and updates the local spec file before generation. # Not supported with "all". @@ -61,16 +61,25 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# The edition list lives in editions.txt so this script and the tests that check its -# output read the same source. One name per line; blank lines and # comments ignored. +# Which editions exist. editions.txt is the shared source for this script, +# scripts/build-packages.sh and tests/test_common_overlay.py, so none of them has to +# parse another's formatting. It governs that list only — the per-edition controller +# thresholds below, and the spec/ directories, still need their own edits. +# +# Format: one name per line; blank lines and # comments ignored, surrounding whitespace +# trimmed. `read -r line` with the default IFS does that trimming, which is exactly +# str.strip() in the Python mirror — do not add `tr -d [:space:]`, which would also +# delete whitespace *inside* a line and silently disagree with it. EDITIONS=() +edition_count=0 # counted rather than ${#EDITIONS[@]}, which is unbound under set -u + # on bash < 4.4 when the array is empty # `|| [ -n "$line" ]` so a final line with no trailing newline is not dropped. while read -r line || [ -n "$line" ]; do - line="$(echo "$line" | tr -d '[:space:]')" case "$line" in ''|'#'*) continue ;; esac EDITIONS+=("$line") + edition_count=$((edition_count + 1)) done < "$SCRIPT_DIR/editions.txt" -if [ ${#EDITIONS[@]} -eq 0 ]; then +if [ "$edition_count" -eq 0 ]; then echo "Error: no editions listed in $SCRIPT_DIR/editions.txt"; exit 1 fi @@ -80,14 +89,17 @@ while [ $# -gt 0 ]; do case "$1" in --verbose) VERBOSE=true; shift ;; --dry-run) DRY_RUN=true; shift ;; + # Lets tests assert against this script's own parse of editions.txt rather than a + # reimplementation of it. Must stay ahead of the JAR download below. + --list-editions) printf '%s\n' "${EDITIONS[@]}"; exit 0 ;; -*) echo "Unknown option: $1"; exit 1 ;; *) break ;; esac done if [ $# -eq 0 ]; then - echo "Usage: $0 [--verbose] [--dry-run] [base-url]" - echo " edition: ce | pe | paas | all" + echo "Usage: $0 [--verbose] [--dry-run] [--list-editions] [base-url]" + echo " edition: ${EDITIONS[*]} | all" echo " base-url: optional, fetches spec from /v3/api-docs/thingsboard" exit 1 fi diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 0beeb9b2..0e667983 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -34,10 +34,15 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 -# Socket timeout for the raw auth calls. urllib3 defaults to no timeout at all, and -# every API thread now blocks behind an in-flight refresh, so an unresponsive auth +# Wall-clock ceiling for a single raw auth call. urllib3 defaults to no timeout at all, +# and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. -AUTH_REQUEST_TIMEOUT_S = 30.0 +# +# Applied as Timeout(total=...) with retries disabled, so it is the real ceiling: a bare +# float sets connect and read separately and leaves total unbounded, and PoolManager +# otherwise applies Retry.DEFAULT (total=3) — whose connection-error branch does not +# consult allowed_methods, so even POST would retry and cost 4x this value. +DEFAULT_AUTH_TIMEOUT_MS = 30_000 # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a @@ -121,14 +126,24 @@ class _AuthManager: The auth mode is decided once in __init__ and never re-derived per request. """ - def __init__(self, base_url: str, api_key: "str | None" = None): + def __init__( + self, + base_url: str, + api_key: "str | None" = None, + auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS, + ): """ Args: base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). Trailing slashes are stripped. api_key: The API key string for API key auth, or None for JWT auth (username/password or an externally supplied token). + auth_timeout_ms: Ceiling for a single /api/auth call, in milliseconds. + Bounds how long every other thread can be blocked behind a + refresh, so it is a knob a slow on-prem server or a + latency-sensitive caller will want to change. """ + self._auth_timeout_s = auth_timeout_ms / 1000 self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX @@ -265,8 +280,11 @@ def _raw_post(self, path: str, body: bytes) -> dict: inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). - Bounded by AUTH_REQUEST_TIMEOUT_S: this call is on the critical path for - every thread waiting on a refresh, so it must not be able to hang forever. + Bounded by auth_timeout_ms: this call is on the critical path for every thread + waiting on a refresh, so it must not be able to hang forever. retries=False + because urllib3 would otherwise retry this POST on connection errors — see + DEFAULT_AUTH_TIMEOUT_MS — and auth POSTs are not idempotent anyway; a failed + refresh already falls back to _do_login. """ http = urllib3.PoolManager() response = http.request( @@ -274,7 +292,8 @@ def _raw_post(self, path: str, body: bytes) -> dict: self._base_url + path, body=body, headers={"Content-Type": "application/json"}, - timeout=AUTH_REQUEST_TIMEOUT_S, + timeout=urllib3.Timeout(total=self._auth_timeout_s), + retries=False, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index f4133645..8da07543 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -27,7 +27,7 @@ import importlib -from ._auth import _AuthManager +from ._auth import DEFAULT_AUTH_TIMEOUT_MS, _AuthManager from ._controller_map import _CONTROLLER_ATTR_MAP, _CONTROLLER_MAP from ._retry import _RetryingRESTClient from .api_client import ApiClient @@ -49,7 +49,8 @@ def _validate_auth_args( # An empty string passes every "is not None" check below but installs no header, # so the client would silently send no credentials at all — the failure mode this # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). - # Checked first so that reaches the caller instead of a downstream collision. + # Checked before the rules below so an empty value reports itself rather than the + # companion-argument error it would also trip. for name, value in ( ("username", username), ("password", password), @@ -83,12 +84,16 @@ def _validate_auth_args( # left to LoginRequest, whose password is a required StrictStr — otherwise the # caller gets a pydantic ValidationError from inside the generated model. if password is not None and username is None: - raise ValueError("password= requires username=") + raise ValueError( + "password= requires username=; pass both, or omit both for an unauthenticated client." + ) if username is not None and password is None: - raise ValueError("username= requires password=") + raise ValueError( + "username= requires password=; pass both, or omit both for an unauthenticated client." + ) # refresh_token= is likewise read only by the token branch. if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=") + raise ValueError("refresh_token= requires token=; pass token=, or omit both.") class ThingsboardClient: @@ -131,6 +136,7 @@ def __init__( initial_retry_delay_ms: int = 1_000, max_retry_delay_ms: int = 30_000, retry_on_rate_limit: bool = True, + auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS, ): """Construct ThingsboardClient and authenticate. @@ -147,6 +153,8 @@ def __init__( max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). retry_on_rate_limit: If True (default), wraps rest_client with _RetryingRESTClient. If False, uses plain RESTClientObject. + auth_timeout_ms: Ceiling for a single /api/auth call (default 30000). + Bounds how long other threads block behind a token refresh. Raises: ValueError: If more than one of username=, api_key= or token= is given; @@ -168,7 +176,7 @@ def __init__( configuration = Configuration(host=url) - auth_manager = _AuthManager(url, api_key) + auth_manager = _AuthManager(url, api_key, auth_timeout_ms) # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 0beeb9b2..0e667983 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -34,10 +34,15 @@ # Matches Java's AuthManager.AVG_REQUEST_TIMEOUT (30 seconds in ms) AVG_REQUEST_TIMEOUT_MS = 30_000 -# Socket timeout for the raw auth calls. urllib3 defaults to no timeout at all, and -# every API thread now blocks behind an in-flight refresh, so an unresponsive auth +# Wall-clock ceiling for a single raw auth call. urllib3 defaults to no timeout at all, +# and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. -AUTH_REQUEST_TIMEOUT_S = 30.0 +# +# Applied as Timeout(total=...) with retries disabled, so it is the real ceiling: a bare +# float sets connect and read separately and leaves total unbounded, and PoolManager +# otherwise applies Retry.DEFAULT (total=3) — whose connection-error branch does not +# consult allowed_methods, so even POST would retry and cost 4x this value. +DEFAULT_AUTH_TIMEOUT_MS = 30_000 # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a @@ -121,14 +126,24 @@ class _AuthManager: The auth mode is decided once in __init__ and never re-derived per request. """ - def __init__(self, base_url: str, api_key: "str | None" = None): + def __init__( + self, + base_url: str, + api_key: "str | None" = None, + auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS, + ): """ Args: base_url: ThingsBoard server URL (e.g. "http://tb-server:9090"). Trailing slashes are stripped. api_key: The API key string for API key auth, or None for JWT auth (username/password or an externally supplied token). + auth_timeout_ms: Ceiling for a single /api/auth call, in milliseconds. + Bounds how long every other thread can be blocked behind a + refresh, so it is a knob a slow on-prem server or a + latency-sensitive caller will want to change. """ + self._auth_timeout_s = auth_timeout_ms / 1000 self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None self._header_prefix = _API_KEY_PREFIX if self._is_api_key else _JWT_PREFIX @@ -265,8 +280,11 @@ def _raw_post(self, path: str, body: bytes) -> dict: inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). - Bounded by AUTH_REQUEST_TIMEOUT_S: this call is on the critical path for - every thread waiting on a refresh, so it must not be able to hang forever. + Bounded by auth_timeout_ms: this call is on the critical path for every thread + waiting on a refresh, so it must not be able to hang forever. retries=False + because urllib3 would otherwise retry this POST on connection errors — see + DEFAULT_AUTH_TIMEOUT_MS — and auth POSTs are not idempotent anyway; a failed + refresh already falls back to _do_login. """ http = urllib3.PoolManager() response = http.request( @@ -274,7 +292,8 @@ def _raw_post(self, path: str, body: bytes) -> dict: self._base_url + path, body=body, headers={"Content-Type": "application/json"}, - timeout=AUTH_REQUEST_TIMEOUT_S, + timeout=urllib3.Timeout(total=self._auth_timeout_s), + retries=False, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index f4133645..8da07543 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -27,7 +27,7 @@ import importlib -from ._auth import _AuthManager +from ._auth import DEFAULT_AUTH_TIMEOUT_MS, _AuthManager from ._controller_map import _CONTROLLER_ATTR_MAP, _CONTROLLER_MAP from ._retry import _RetryingRESTClient from .api_client import ApiClient @@ -49,7 +49,8 @@ def _validate_auth_args( # An empty string passes every "is not None" check below but installs no header, # so the client would silently send no credentials at all — the failure mode this # validation exists to prevent. Easy to reach via os.environ.get("TB_API_KEY", ""). - # Checked first so that reaches the caller instead of a downstream collision. + # Checked before the rules below so an empty value reports itself rather than the + # companion-argument error it would also trip. for name, value in ( ("username", username), ("password", password), @@ -83,12 +84,16 @@ def _validate_auth_args( # left to LoginRequest, whose password is a required StrictStr — otherwise the # caller gets a pydantic ValidationError from inside the generated model. if password is not None and username is None: - raise ValueError("password= requires username=") + raise ValueError( + "password= requires username=; pass both, or omit both for an unauthenticated client." + ) if username is not None and password is None: - raise ValueError("username= requires password=") + raise ValueError( + "username= requires password=; pass both, or omit both for an unauthenticated client." + ) # refresh_token= is likewise read only by the token branch. if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=") + raise ValueError("refresh_token= requires token=; pass token=, or omit both.") class ThingsboardClient: @@ -131,6 +136,7 @@ def __init__( initial_retry_delay_ms: int = 1_000, max_retry_delay_ms: int = 30_000, retry_on_rate_limit: bool = True, + auth_timeout_ms: int = DEFAULT_AUTH_TIMEOUT_MS, ): """Construct ThingsboardClient and authenticate. @@ -147,6 +153,8 @@ def __init__( max_retry_delay_ms: Maximum backoff cap in milliseconds (default 30000). retry_on_rate_limit: If True (default), wraps rest_client with _RetryingRESTClient. If False, uses plain RESTClientObject. + auth_timeout_ms: Ceiling for a single /api/auth call (default 30000). + Bounds how long other threads block behind a token refresh. Raises: ValueError: If more than one of username=, api_key= or token= is given; @@ -168,7 +176,7 @@ def __init__( configuration = Configuration(host=url) - auth_manager = _AuthManager(url, api_key) + auth_manager = _AuthManager(url, api_key, auth_timeout_ms) # Install the refresh hook so the hook fires before every API request configuration.refresh_api_key_hook = auth_manager.hook diff --git a/scripts/build-packages.sh b/scripts/build-packages.sh index af8d3773..1268f0fd 100755 --- a/scripts/build-packages.sh +++ b/scripts/build-packages.sh @@ -41,7 +41,12 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$SCRIPT_DIR/.." DIST_DIR="$ROOT_DIR/dist" -EDITIONS=("ce" "pe" "paas") +# Editions come from generate-client.sh's own parse of editions.txt, so this script +# never reimplements that format — see the comment on the EDITIONS block there. +EDITIONS=() +while read -r line; do + EDITIONS+=("$line") +done < <("$ROOT_DIR/generate-client.sh" --list-editions) # Add project venv to PATH so that tools installed via pip install (e.g. poetry) # are accessible without requiring a manual `source .venv/bin/activate`. diff --git a/tests/test_auth.py b/tests/test_auth.py index 647a165d..39c52267 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -8,7 +8,9 @@ import unittest from unittest.mock import MagicMock, patch -from common._auth import _AuthManager, _parse_jwt_claim_ms +import urllib3 + +from common._auth import DEFAULT_AUTH_TIMEOUT_MS, _AuthManager, _parse_jwt_claim_ms from tests._jwt import make_jwt, make_refresh_token, make_token # --------------------------------------------------------------------------- @@ -218,25 +220,45 @@ def side_effect(path, body): # --------------------------------------------------------------------------- -class TestRawPostTimeout(unittest.TestCase): - def test_raw_post_bounds_the_request(self): - """_raw_post passes a timeout — urllib3 defaults to none. +def _raw_post_kwargs(auth): + """Run _raw_post against a mocked PoolManager and return the request kwargs.""" + response = MagicMock() + response.status = 200 + response.data = b'{"token": "t", "refreshToken": "r"}' + with patch("common._auth.urllib3.PoolManager") as mock_pool: + mock_pool.return_value.request.return_value = response + auth._raw_post("/api/auth/login", b"{}") + return mock_pool.return_value.request.call_args.kwargs + + +class TestRawPostBounds(unittest.TestCase): + """The auth round-trip is the one call every other thread waits on.""" - Every API thread blocks behind an in-flight refresh, so an unresponsive auth - endpoint without this would hang the process rather than a single thread. + def test_timeout_is_a_total_and_uses_the_configured_value(self): + """The ceiling is a Timeout(total=...), not a bare float. + + A bare float sets connect and read separately and leaves total unbounded, so a + slow-drip response would never hit the limit the constant advertises. """ - auth = _AuthManager("http://tb:9090") - response = MagicMock() - response.status = 200 - response.data = b'{"token": "t", "refreshToken": "r"}' + kwargs = _raw_post_kwargs(_AuthManager("http://tb:9090")) + + timeout = kwargs.get("timeout") + self.assertIsInstance(timeout, urllib3.Timeout) + self.assertEqual(timeout.total, DEFAULT_AUTH_TIMEOUT_MS / 1000) + + def test_retries_disabled(self): + """urllib3 would otherwise apply Retry.DEFAULT (total=3). + + Its connection-error branch never consults allowed_methods, so this POST would + retry too and cost 4x the advertised ceiling before _do_login tries again. + """ + self.assertIs(_raw_post_kwargs(_AuthManager("http://tb:9090")).get("retries"), False) - with patch("common._auth.urllib3.PoolManager") as mock_pool: - mock_pool.return_value.request.return_value = response - auth._raw_post("/api/auth/login", b"{}") + def test_timeout_is_configurable(self): + """auth_timeout_ms reaches the request, in seconds.""" + kwargs = _raw_post_kwargs(_AuthManager("http://tb:9090", auth_timeout_ms=1_500)) - timeout = mock_pool.return_value.request.call_args.kwargs.get("timeout") - self.assertIsNotNone(timeout, "_raw_post sent no timeout") - self.assertGreater(timeout, 0) + self.assertEqual(kwargs["timeout"].total, 1.5) class TestConcurrentRefresh(unittest.TestCase): diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 02048190..6d53ebf2 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -14,6 +14,7 @@ fails instead of quietly dropping out. """ +import subprocess from pathlib import Path import pytest @@ -76,7 +77,13 @@ def _editions(path: Path = _EDITIONS_FILE) -> list[str]: One name per line; blank lines and # comments ignored, matching the read loop in generate-client.sh. Reading the list rather than parsing it out of the shell script means neither side's formatting is load-bearing for the other. + + A missing file yields an empty list rather than raising, matching the two walk + helpers: this runs at collection time from the parametrize decorators below, so + raising here would take the unrelated package-sync cases down with it. """ + if not path.is_file(): + return [] lines = path.read_text(encoding="utf-8").splitlines() return sorted(s for line in lines if (s := line.strip()) and not s.startswith("#")) @@ -89,7 +96,7 @@ def test_discovery_finds_filenames_and_editions(): """ assert _overlaid_filenames(), "no overlaid files discovered in common/" assert _overlaid_doc_filenames(), "no overlaid docs discovered in common/docs/" - assert _editions(), "no editions parsed from generate-client.sh" + assert _editions(), f"no editions listed in {_EDITIONS_FILE.name}" def _assert_identical(source: Path, copy: Path, destinations: str) -> None: @@ -137,12 +144,41 @@ def test_walk_exclusion_semantics(tmp_path): ] +# Lines chosen so that a parser disagreeing with the script's fails: interior +# whitespace separates `tr -d [:space:]` from str.strip(), a trailing comment separates +# a naive '#' strip from a whole-line one, and the file ends without a newline. +_EDITIONS_FIXTURE = "# a comment\n\nce\n pe \n\n# not shipped yet\npa as\nce # note" + + def test_editions_parsing_matches_the_script(tmp_path): - """Blank lines and # comments are ignored, mirroring the script's grep.""" + """The Python parser agrees with generate-client.sh on the same file. + + Runs the script rather than a restatement of its rules — the two implementations + are the thing at risk of drifting, so pinning only the Python side would let a + divergence sit here undetected. + """ listing = tmp_path / "editions.txt" - listing.write_text("# a comment\n\nce\n pe \n\n# paas is not shipped yet\npaas\n") + listing.write_text(_EDITIONS_FIXTURE, encoding="utf-8") + script = tmp_path / "generate-client.sh" + script.write_bytes((_REPO_ROOT / "generate-client.sh").read_bytes()) + script.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "--list-editions"], + capture_output=True, + text=True, + check=True, + ) + from_script = sorted(result.stdout.splitlines()) + + assert from_script == _editions(listing) + # Spelled out too, so a change that broke both sides identically still fails. + assert from_script == ["ce", "ce # note", "pa as", "pe"] + - assert _editions(listing) == ["ce", "paas", "pe"] +def test_editions_missing_file_yields_empty_list(tmp_path): + """A lost editions.txt is reported by the discovery test, not by a collection error.""" + assert _editions(tmp_path / "editions.txt") == [] def test_doc_walk_is_flat(tmp_path): From a0f7869e0ac5fd05df7c72b70c6c05bbb5f2e1be Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 4 Aug 2026 10:01:08 +0300 Subject: [PATCH 15/17] Address thirteenth review: keep auth redirects working, fail loudly on a bad edition list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retries=False turned off redirect *following*, not just retries: it means Retry(0, read=False), which governs redirects too. Confirmed against a local server that 307s /api/auth/login — urllib3 handed the 307 straight back and _raw_post raised RuntimeError, while the generated RESTClientObject kept following redirects, so a redirecting deployment would have failed at auth only. Now Retry(connect=0, read=0, status=0, other=0, redirect=3): verified one connect attempt on a refused port (vs 4 on urllib3's default) and a 307 followed to 200, end to end through _raw_post. Why single-attempt is deliberate, and why redirect stays on, are recorded next to the constant. - Reject non-positive auth_timeout_ms in _AuthManager.__init__. urllib3.Timeout raises for it, but only inside _raw_post, where _do_refresh_token and _do_login catch Exception and log — so the client built fine and then silently never refreshed. - scripts/build-packages.sh read --list-editions through process substitution, which discards the child's exit status: a failing call left EDITIONS empty and the script exited 0 having built nothing. Reproduced with a stub exiting 1. Capture into a variable first so set -e sees it, plus an explicit emptiness check. The wheel-count verification and the header no longer hardcode three. - Drop edition_count for `[ -z "${EDITIONS[*]:-}" ]` — same old-bash safety with no parallel state to drift. - Give refresh_token= the same remedy clause as the other two companion errors. - _editions() docstring now states the real reason the mirror exists (collection cost and hermeticity), not a claim about formatting that stopped being true when the test started executing the script. --- ce/tb_ce_client/_auth.py | 37 +++++++++++++++++++++++++---------- ce/tb_ce_client/client.py | 8 +++++--- common/_auth.py | 37 +++++++++++++++++++++++++---------- common/client.py | 8 +++++--- generate-client.sh | 7 +++---- paas/tb_paas_client/_auth.py | 37 +++++++++++++++++++++++++---------- paas/tb_paas_client/client.py | 8 +++++--- pe/tb_pe_client/_auth.py | 37 +++++++++++++++++++++++++---------- pe/tb_pe_client/client.py | 8 +++++--- scripts/build-packages.sh | 22 ++++++++++++++------- tests/test_auth.py | 31 +++++++++++++++++++++++++++-- tests/test_common_overlay.py | 7 +++++-- 12 files changed, 180 insertions(+), 67 deletions(-) diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 0e667983..77810090 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -38,12 +38,25 @@ # and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. # -# Applied as Timeout(total=...) with retries disabled, so it is the real ceiling: a bare -# float sets connect and read separately and leaves total unbounded, and PoolManager -# otherwise applies Retry.DEFAULT (total=3) — whose connection-error branch does not -# consult allowed_methods, so even POST would retry and cost 4x this value. +# Applied as Timeout(total=...) so it is the real ceiling: a bare float sets connect and +# read separately and leaves total unbounded. DEFAULT_AUTH_TIMEOUT_MS = 30_000 +# Retry policy for the raw auth calls, spelled out rather than left to urllib3. +# +# No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults +# allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are +# not idempotent, and the timeout exists precisely to bound how long every other thread +# sits blocked, so a single attempt is the deliberate trade — a caller wanting tolerance +# should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that +# would let us have both. +# +# redirect is left on: `retries=False` would be the obvious spelling, but it means +# Retry(0, read=False), which disables redirect *following* too. The generated +# RESTClientObject still follows redirects, so auth alone would break against a +# deployment that redirects (a proxy forcing https, or path normalisation). +_AUTH_RETRIES = urllib3.Retry(connect=0, read=0, status=0, other=0, redirect=3) + # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a # single owner instead of literals scattered across client.py and _auth.py. @@ -143,6 +156,11 @@ def __init__( refresh, so it is a knob a slow on-prem server or a latency-sensitive caller will want to change. """ + # urllib3.Timeout rejects a non-positive total, but it would only raise inside + # _raw_post — where _do_refresh_token and _do_login catch Exception and log — + # so a bad value would construct fine and then silently never refresh. + if auth_timeout_ms <= 0: + raise ValueError(f"auth_timeout_ms must be positive; got {auth_timeout_ms}") self._auth_timeout_s = auth_timeout_ms / 1000 self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None @@ -280,11 +298,10 @@ def _raw_post(self, path: str, body: bytes) -> dict: inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). - Bounded by auth_timeout_ms: this call is on the critical path for every thread - waiting on a refresh, so it must not be able to hang forever. retries=False - because urllib3 would otherwise retry this POST on connection errors — see - DEFAULT_AUTH_TIMEOUT_MS — and auth POSTs are not idempotent anyway; a failed - refresh already falls back to _do_login. + Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path + for every thread waiting on a refresh, so it must not be able to hang forever, + and it does not retry. See _AUTH_RETRIES for why that trade is deliberate and + why redirects are still followed. """ http = urllib3.PoolManager() response = http.request( @@ -293,7 +310,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: body=body, headers={"Content-Type": "application/json"}, timeout=urllib3.Timeout(total=self._auth_timeout_s), - retries=False, + retries=_AUTH_RETRIES, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/ce/tb_ce_client/client.py b/ce/tb_ce_client/client.py index 8da07543..5947faa4 100644 --- a/ce/tb_ce_client/client.py +++ b/ce/tb_ce_client/client.py @@ -93,7 +93,9 @@ def _validate_auth_args( ) # refresh_token= is likewise read only by the token branch. if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=; pass token=, or omit both.") + raise ValueError( + "refresh_token= requires token=; pass both, or omit both for an unauthenticated client." + ) class ThingsboardClient: @@ -159,8 +161,8 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; if password= is given without username= or vice versa; if - refresh_token= is given without token=; or if any auth argument is - an empty string. + refresh_token= is given without token=; if any auth argument is + an empty string; or if auth_timeout_ms is not positive. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). diff --git a/common/_auth.py b/common/_auth.py index 0e667983..77810090 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -38,12 +38,25 @@ # and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. # -# Applied as Timeout(total=...) with retries disabled, so it is the real ceiling: a bare -# float sets connect and read separately and leaves total unbounded, and PoolManager -# otherwise applies Retry.DEFAULT (total=3) — whose connection-error branch does not -# consult allowed_methods, so even POST would retry and cost 4x this value. +# Applied as Timeout(total=...) so it is the real ceiling: a bare float sets connect and +# read separately and leaves total unbounded. DEFAULT_AUTH_TIMEOUT_MS = 30_000 +# Retry policy for the raw auth calls, spelled out rather than left to urllib3. +# +# No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults +# allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are +# not idempotent, and the timeout exists precisely to bound how long every other thread +# sits blocked, so a single attempt is the deliberate trade — a caller wanting tolerance +# should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that +# would let us have both. +# +# redirect is left on: `retries=False` would be the obvious spelling, but it means +# Retry(0, read=False), which disables redirect *following* too. The generated +# RESTClientObject still follows redirects, so auth alone would break against a +# deployment that redirects (a proxy forcing https, or path normalisation). +_AUTH_RETRIES = urllib3.Retry(connect=0, read=0, status=0, other=0, redirect=3) + # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a # single owner instead of literals scattered across client.py and _auth.py. @@ -143,6 +156,11 @@ def __init__( refresh, so it is a knob a slow on-prem server or a latency-sensitive caller will want to change. """ + # urllib3.Timeout rejects a non-positive total, but it would only raise inside + # _raw_post — where _do_refresh_token and _do_login catch Exception and log — + # so a bad value would construct fine and then silently never refresh. + if auth_timeout_ms <= 0: + raise ValueError(f"auth_timeout_ms must be positive; got {auth_timeout_ms}") self._auth_timeout_s = auth_timeout_ms / 1000 self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None @@ -280,11 +298,10 @@ def _raw_post(self, path: str, body: bytes) -> dict: inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). - Bounded by auth_timeout_ms: this call is on the critical path for every thread - waiting on a refresh, so it must not be able to hang forever. retries=False - because urllib3 would otherwise retry this POST on connection errors — see - DEFAULT_AUTH_TIMEOUT_MS — and auth POSTs are not idempotent anyway; a failed - refresh already falls back to _do_login. + Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path + for every thread waiting on a refresh, so it must not be able to hang forever, + and it does not retry. See _AUTH_RETRIES for why that trade is deliberate and + why redirects are still followed. """ http = urllib3.PoolManager() response = http.request( @@ -293,7 +310,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: body=body, headers={"Content-Type": "application/json"}, timeout=urllib3.Timeout(total=self._auth_timeout_s), - retries=False, + retries=_AUTH_RETRIES, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/common/client.py b/common/client.py index 8da07543..5947faa4 100644 --- a/common/client.py +++ b/common/client.py @@ -93,7 +93,9 @@ def _validate_auth_args( ) # refresh_token= is likewise read only by the token branch. if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=; pass token=, or omit both.") + raise ValueError( + "refresh_token= requires token=; pass both, or omit both for an unauthenticated client." + ) class ThingsboardClient: @@ -159,8 +161,8 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; if password= is given without username= or vice versa; if - refresh_token= is given without token=; or if any auth argument is - an empty string. + refresh_token= is given without token=; if any auth argument is + an empty string; or if auth_timeout_ms is not positive. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). diff --git a/generate-client.sh b/generate-client.sh index cc372794..ea4742fe 100755 --- a/generate-client.sh +++ b/generate-client.sh @@ -71,15 +71,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # str.strip() in the Python mirror — do not add `tr -d [:space:]`, which would also # delete whitespace *inside* a line and silently disagree with it. EDITIONS=() -edition_count=0 # counted rather than ${#EDITIONS[@]}, which is unbound under set -u - # on bash < 4.4 when the array is empty # `|| [ -n "$line" ]` so a final line with no trailing newline is not dropped. while read -r line || [ -n "$line" ]; do case "$line" in ''|'#'*) continue ;; esac EDITIONS+=("$line") - edition_count=$((edition_count + 1)) done < "$SCRIPT_DIR/editions.txt" -if [ "$edition_count" -eq 0 ]; then +# `${EDITIONS[*]:-}` rather than ${#EDITIONS[@]}: the latter is unbound under set -u on +# bash < 4.4 when the array is empty, which is exactly the case being tested for. +if [ -z "${EDITIONS[*]:-}" ]; then echo "Error: no editions listed in $SCRIPT_DIR/editions.txt"; exit 1 fi diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 0e667983..77810090 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -38,12 +38,25 @@ # and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. # -# Applied as Timeout(total=...) with retries disabled, so it is the real ceiling: a bare -# float sets connect and read separately and leaves total unbounded, and PoolManager -# otherwise applies Retry.DEFAULT (total=3) — whose connection-error branch does not -# consult allowed_methods, so even POST would retry and cost 4x this value. +# Applied as Timeout(total=...) so it is the real ceiling: a bare float sets connect and +# read separately and leaves total unbounded. DEFAULT_AUTH_TIMEOUT_MS = 30_000 +# Retry policy for the raw auth calls, spelled out rather than left to urllib3. +# +# No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults +# allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are +# not idempotent, and the timeout exists precisely to bound how long every other thread +# sits blocked, so a single attempt is the deliberate trade — a caller wanting tolerance +# should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that +# would let us have both. +# +# redirect is left on: `retries=False` would be the obvious spelling, but it means +# Retry(0, read=False), which disables redirect *following* too. The generated +# RESTClientObject still follows redirects, so auth alone would break against a +# deployment that redirects (a proxy forcing https, or path normalisation). +_AUTH_RETRIES = urllib3.Retry(connect=0, read=0, status=0, other=0, redirect=3) + # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a # single owner instead of literals scattered across client.py and _auth.py. @@ -143,6 +156,11 @@ def __init__( refresh, so it is a knob a slow on-prem server or a latency-sensitive caller will want to change. """ + # urllib3.Timeout rejects a non-positive total, but it would only raise inside + # _raw_post — where _do_refresh_token and _do_login catch Exception and log — + # so a bad value would construct fine and then silently never refresh. + if auth_timeout_ms <= 0: + raise ValueError(f"auth_timeout_ms must be positive; got {auth_timeout_ms}") self._auth_timeout_s = auth_timeout_ms / 1000 self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None @@ -280,11 +298,10 @@ def _raw_post(self, path: str, body: bytes) -> dict: inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). - Bounded by auth_timeout_ms: this call is on the critical path for every thread - waiting on a refresh, so it must not be able to hang forever. retries=False - because urllib3 would otherwise retry this POST on connection errors — see - DEFAULT_AUTH_TIMEOUT_MS — and auth POSTs are not idempotent anyway; a failed - refresh already falls back to _do_login. + Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path + for every thread waiting on a refresh, so it must not be able to hang forever, + and it does not retry. See _AUTH_RETRIES for why that trade is deliberate and + why redirects are still followed. """ http = urllib3.PoolManager() response = http.request( @@ -293,7 +310,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: body=body, headers={"Content-Type": "application/json"}, timeout=urllib3.Timeout(total=self._auth_timeout_s), - retries=False, + retries=_AUTH_RETRIES, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/paas/tb_paas_client/client.py b/paas/tb_paas_client/client.py index 8da07543..5947faa4 100644 --- a/paas/tb_paas_client/client.py +++ b/paas/tb_paas_client/client.py @@ -93,7 +93,9 @@ def _validate_auth_args( ) # refresh_token= is likewise read only by the token branch. if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=; pass token=, or omit both.") + raise ValueError( + "refresh_token= requires token=; pass both, or omit both for an unauthenticated client." + ) class ThingsboardClient: @@ -159,8 +161,8 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; if password= is given without username= or vice versa; if - refresh_token= is given without token=; or if any auth argument is - an empty string. + refresh_token= is given without token=; if any auth argument is + an empty string; or if auth_timeout_ms is not positive. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 0e667983..77810090 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -38,12 +38,25 @@ # and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. # -# Applied as Timeout(total=...) with retries disabled, so it is the real ceiling: a bare -# float sets connect and read separately and leaves total unbounded, and PoolManager -# otherwise applies Retry.DEFAULT (total=3) — whose connection-error branch does not -# consult allowed_methods, so even POST would retry and cost 4x this value. +# Applied as Timeout(total=...) so it is the real ceiling: a bare float sets connect and +# read separately and leaves total unbounded. DEFAULT_AUTH_TIMEOUT_MS = 30_000 +# Retry policy for the raw auth calls, spelled out rather than left to urllib3. +# +# No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults +# allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are +# not idempotent, and the timeout exists precisely to bound how long every other thread +# sits blocked, so a single attempt is the deliberate trade — a caller wanting tolerance +# should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that +# would let us have both. +# +# redirect is left on: `retries=False` would be the obvious spelling, but it means +# Retry(0, read=False), which disables redirect *following* too. The generated +# RESTClientObject still follows redirects, so auth alone would break against a +# deployment that redirects (a proxy forcing https, or path normalisation). +_AUTH_RETRIES = urllib3.Retry(connect=0, read=0, status=0, other=0, redirect=3) + # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a # single owner instead of literals scattered across client.py and _auth.py. @@ -143,6 +156,11 @@ def __init__( refresh, so it is a knob a slow on-prem server or a latency-sensitive caller will want to change. """ + # urllib3.Timeout rejects a non-positive total, but it would only raise inside + # _raw_post — where _do_refresh_token and _do_login catch Exception and log — + # so a bad value would construct fine and then silently never refresh. + if auth_timeout_ms <= 0: + raise ValueError(f"auth_timeout_ms must be positive; got {auth_timeout_ms}") self._auth_timeout_s = auth_timeout_ms / 1000 self._base_url = base_url.rstrip("/") self._is_api_key = api_key is not None @@ -280,11 +298,10 @@ def _raw_post(self, path: str, body: bytes) -> dict: inside the hook, causing infinite recursion (mirrors Java's pattern of using a separate raw HttpClient for AuthManager calls). - Bounded by auth_timeout_ms: this call is on the critical path for every thread - waiting on a refresh, so it must not be able to hang forever. retries=False - because urllib3 would otherwise retry this POST on connection errors — see - DEFAULT_AUTH_TIMEOUT_MS — and auth POSTs are not idempotent anyway; a failed - refresh already falls back to _do_login. + Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path + for every thread waiting on a refresh, so it must not be able to hang forever, + and it does not retry. See _AUTH_RETRIES for why that trade is deliberate and + why redirects are still followed. """ http = urllib3.PoolManager() response = http.request( @@ -293,7 +310,7 @@ def _raw_post(self, path: str, body: bytes) -> dict: body=body, headers={"Content-Type": "application/json"}, timeout=urllib3.Timeout(total=self._auth_timeout_s), - retries=False, + retries=_AUTH_RETRIES, ) if response.status != 200: raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") diff --git a/pe/tb_pe_client/client.py b/pe/tb_pe_client/client.py index 8da07543..5947faa4 100644 --- a/pe/tb_pe_client/client.py +++ b/pe/tb_pe_client/client.py @@ -93,7 +93,9 @@ def _validate_auth_args( ) # refresh_token= is likewise read only by the token branch. if refresh_token is not None and token is None: - raise ValueError("refresh_token= requires token=; pass token=, or omit both.") + raise ValueError( + "refresh_token= requires token=; pass both, or omit both for an unauthenticated client." + ) class ThingsboardClient: @@ -159,8 +161,8 @@ def __init__( Raises: ValueError: If more than one of username=, api_key= or token= is given; if password= is given without username= or vice versa; if - refresh_token= is given without token=; or if any auth argument is - an empty string. + refresh_token= is given without token=; if any auth argument is + an empty string; or if auth_timeout_ms is not positive. """ # Must be the very first assignment — prevents __getattr__ infinite recursion # if __init__ raises partway through (before self.api_client is set). diff --git a/scripts/build-packages.sh b/scripts/build-packages.sh index 1268f0fd..1ccc041f 100755 --- a/scripts/build-packages.sh +++ b/scripts/build-packages.sh @@ -16,8 +16,8 @@ # # -# Builds Python wheel and sdist packages for all three ThingsBoard client editions -# (CE, PE, PaaS), including version stamping, generation, and smoke testing. +# Builds Python wheel and sdist packages for every ThingsBoard client edition listed in +# editions.txt, including version stamping, generation, and smoke testing. # # Usage: # ./scripts/build-packages.sh @@ -43,10 +43,18 @@ ROOT_DIR="$SCRIPT_DIR/.." DIST_DIR="$ROOT_DIR/dist" # Editions come from generate-client.sh's own parse of editions.txt, so this script # never reimplements that format — see the comment on the EDITIONS block there. +# Captured into a variable first: process substitution discards the child's exit status, +# so `set -e` would not see --list-editions fail and we would build nothing, silently. +editions_output="$("$ROOT_DIR/generate-client.sh" --list-editions)" EDITIONS=() while read -r line; do - EDITIONS+=("$line") -done < <("$ROOT_DIR/generate-client.sh" --list-editions) + [ -n "$line" ] && EDITIONS+=("$line") +done <<< "$editions_output" +if [ -z "${EDITIONS[*]:-}" ]; then + echo "Error: generate-client.sh --list-editions returned no editions" >&2 + exit 1 +fi +EDITION_COUNT=${#EDITIONS[@]} # Add project venv to PATH so that tools installed via pip install (e.g. poetry) # are accessible without requiring a manual `source .venv/bin/activate`. @@ -207,11 +215,11 @@ for edition in "${EDITIONS[@]}"; do ok " ${edition}: $(basename "$wheel")" done -if [ "$WHEEL_COUNT" -ne 3 ]; then - fail "Expected 3 wheels, found $WHEEL_COUNT" +if [ "$WHEEL_COUNT" -ne "$EDITION_COUNT" ]; then + fail "Expected $EDITION_COUNT wheels, found $WHEEL_COUNT" fi -ok "All 3 editions built with version ${VERSION}" +ok "All $EDITION_COUNT editions built with version ${VERSION}" # --------------------------------------------------------------------------- # 7. Print summary diff --git a/tests/test_auth.py b/tests/test_auth.py index 39c52267..b3821fdb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -246,13 +246,28 @@ def test_timeout_is_a_total_and_uses_the_configured_value(self): self.assertIsInstance(timeout, urllib3.Timeout) self.assertEqual(timeout.total, DEFAULT_AUTH_TIMEOUT_MS / 1000) - def test_retries_disabled(self): + def test_does_not_retry(self): """urllib3 would otherwise apply Retry.DEFAULT (total=3). Its connection-error branch never consults allowed_methods, so this POST would retry too and cost 4x the advertised ceiling before _do_login tries again. """ - self.assertIs(_raw_post_kwargs(_AuthManager("http://tb:9090")).get("retries"), False) + retries = _raw_post_kwargs(_AuthManager("http://tb:9090")).get("retries") + + self.assertIsInstance(retries, urllib3.Retry) + self.assertEqual( + (retries.connect, retries.read, retries.status, retries.other), (0, 0, 0, 0) + ) + + def test_still_follows_redirects(self): + """Not spelled `retries=False`, which would disable redirect following too. + + The generated RESTClientObject follows redirects, so auth alone breaking against + a redirecting deployment would be a confusing partial failure. + """ + retries = _raw_post_kwargs(_AuthManager("http://tb:9090")).get("retries") + + self.assertTrue(retries.redirect, "auth requests would stop following redirects") def test_timeout_is_configurable(self): """auth_timeout_ms reaches the request, in seconds.""" @@ -260,6 +275,18 @@ def test_timeout_is_configurable(self): self.assertEqual(kwargs["timeout"].total, 1.5) + def test_non_positive_timeout_rejected(self): + """Rejected at construction, not on the first refresh. + + urllib3.Timeout raises for a non-positive total, but only inside _raw_post, + where _do_refresh_token and _do_login catch Exception and log — so the client + would build fine and then silently stop refreshing. + """ + for bad in (0, -1): + with self.subTest(auth_timeout_ms=bad): + with self.assertRaisesRegex(ValueError, "auth_timeout_ms must be positive"): + _AuthManager("http://tb:9090", auth_timeout_ms=bad) + class TestConcurrentRefresh(unittest.TestCase): def test_second_thread_waits_for_in_flight_refresh(self): diff --git a/tests/test_common_overlay.py b/tests/test_common_overlay.py index 6d53ebf2..1d742aec 100644 --- a/tests/test_common_overlay.py +++ b/tests/test_common_overlay.py @@ -75,8 +75,11 @@ def _editions(path: Path = _EDITIONS_FILE) -> list[str]: """Edition names read from editions.txt — the same file generate-client.sh reads. One name per line; blank lines and # comments ignored, matching the read loop in - generate-client.sh. Reading the list rather than parsing it out of the shell script - means neither side's formatting is load-bearing for the other. + generate-client.sh. That script's --list-editions is the canonical parser; this is a + mirror of it, kept rather than shelled out to because it runs at collection time from + the parametrize decorators below — where a subprocess would be paid on every run, and + would make collection depend on bash and on the script succeeding. + test_editions_parsing_matches_the_script holds the two together. A missing file yields an empty list rather than raising, matching the two walk helpers: this runs at collection time from the parametrize decorators below, so From b04fdf057f9f04a5a1d5f02a76b2b531568ab64a Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 4 Aug 2026 10:56:46 +0300 Subject: [PATCH 16/17] Address fourteenth review: stop following auth redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redirect=3 (from the last round) carried two consequences, both measured: - urllib3 clones the timeout per hop rather than drawing down a shared budget, so the ceiling became (1 + redirect) x auth_timeout_ms. Reproduced: Timeout(total=1.0) against four 0.8s hops took 3.21s. That is the same multiplier the retry removal existed to eliminate, on the round-trip every other API thread blocks behind. - A followed redirect re-sends the body to whatever Location names. Reproduced: an origin 307ing /api/auth/login to a second host handed that host {"username": ..., "password": ...} verbatim, and _do_login installed the token it returned. Taking the second option offered rather than redirect=1 plus a same-origin check: _AUTH_RETRIES is now redirect=False, so exactly one request goes out. The case given up is a deployment that redirects auth, and failing loudly is the better answer there — on an http -> https redirect the credentials have already gone out in cleartext, so the fix is to pass the final auth URL. _raw_post says exactly that on any 3xx. Verified after the change: the second host receives nothing and the caller gets the RuntimeError. Also set total=0 explicitly — it defaults to 10, which contradicted the "exactly one request" the block is for, even though the per-class zeros exhaust first. - Pin the retry values exactly, including total and redirect, rather than truthiness. - Add _StubAuthServer and two tests exercising _raw_post over a real socket: a 200 is parsed, and a 307 raises with the remedy in the message. The kwargs tests only ever proved a setting reached urllib3, not the behaviour it was chosen for. - Cover auth_timeout_ms through ThingsboardClient.__init__ with the neighbouring assert_not_called, pinning that rejection precedes the eager login. - Sweep the remaining hardcoded edition counts out of build-packages.sh's header and section banner, and out of test_readme.py's module docstring. --- ce/tb_ce_client/_auth.py | 44 ++++++++++++++++------- common/_auth.py | 44 ++++++++++++++++------- paas/tb_paas_client/_auth.py | 44 ++++++++++++++++------- pe/tb_pe_client/_auth.py | 44 ++++++++++++++++------- scripts/build-packages.sh | 6 ++-- tests/test_auth.py | 68 ++++++++++++++++++++++++++++++++---- tests/test_client.py | 13 +++++++ tests/test_readme.py | 2 +- 8 files changed, 207 insertions(+), 58 deletions(-) diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index 77810090..cb5a3d73 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -38,11 +38,14 @@ # and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. # -# Applied as Timeout(total=...) so it is the real ceiling: a bare float sets connect and -# read separately and leaves total unbounded. +# Applied as Timeout(total=...) rather than a bare float, which would set connect and +# read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one +# request — no retry, no redirect — total is the whole ceiling; urllib3 gives each +# attempt its own budget, so any allowance there would multiply this number. DEFAULT_AUTH_TIMEOUT_MS = 30_000 -# Retry policy for the raw auth calls, spelled out rather than left to urllib3. +# Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that +# exactly one request goes out and DEFAULT_AUTH_TIMEOUT_MS means what it says. # # No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults # allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are @@ -51,11 +54,22 @@ # should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that # would let us have both. # -# redirect is left on: `retries=False` would be the obvious spelling, but it means -# Retry(0, read=False), which disables redirect *following* too. The generated -# RESTClientObject still follows redirects, so auth alone would break against a -# deployment that redirects (a proxy forcing https, or path normalisation). -_AUTH_RETRIES = urllib3.Retry(connect=0, read=0, status=0, other=0, redirect=3) +# No redirects either, which is a departure from the generated RESTClientObject: +# - urllib3 clones the timeout per hop instead of drawing down a shared budget, so +# following N redirects costs (1 + N) x auth_timeout_ms on the one round-trip every +# other API thread is blocked behind. +# - the body is re-sent to whatever Location names, with no same-origin restriction, +# so a redirect out of the configured server hands username/password to a third host +# and _do_login would install the token it returns. +# The case this gives up is a deployment that redirects auth (a proxy forcing https, +# path normalisation). Failing loudly is the better answer there: on an http -> https +# redirect the credentials have already gone out in cleartext, so the fix is to pass the +# final auth URL as url=, which _raw_post's error tells the caller to do. +# +# total is set explicitly: it defaults to 10, and leaving it there would contradict the +# "exactly one request" this whole block is for, even though the per-class zeros already +# exhaust first. urllib3 normalises redirect=False to 0. +_AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False) # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a @@ -299,9 +313,8 @@ def _raw_post(self, path: str, body: bytes) -> dict: of using a separate raw HttpClient for AuthManager calls). Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path - for every thread waiting on a refresh, so it must not be able to hang forever, - and it does not retry. See _AUTH_RETRIES for why that trade is deliberate and - why redirects are still followed. + for every thread waiting on a refresh, so it makes exactly one request — no + retry, no redirect. See _AUTH_RETRIES for why both trades are deliberate. """ http = urllib3.PoolManager() response = http.request( @@ -313,7 +326,14 @@ def _raw_post(self, path: str, body: bytes) -> dict: retries=_AUTH_RETRIES, ) if response.status != 200: - raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") + # 3xx arrives here rather than being followed — say so, since the remedy is + # specific and not guessable from the status alone. + hint = ( + "; auth requests do not follow redirects, so pass the final auth URL as url=" + if 300 <= response.status < 400 + else "" + ) + raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}") return json.loads(response.data) def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo": diff --git a/common/_auth.py b/common/_auth.py index 77810090..cb5a3d73 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -38,11 +38,14 @@ # and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. # -# Applied as Timeout(total=...) so it is the real ceiling: a bare float sets connect and -# read separately and leaves total unbounded. +# Applied as Timeout(total=...) rather than a bare float, which would set connect and +# read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one +# request — no retry, no redirect — total is the whole ceiling; urllib3 gives each +# attempt its own budget, so any allowance there would multiply this number. DEFAULT_AUTH_TIMEOUT_MS = 30_000 -# Retry policy for the raw auth calls, spelled out rather than left to urllib3. +# Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that +# exactly one request goes out and DEFAULT_AUTH_TIMEOUT_MS means what it says. # # No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults # allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are @@ -51,11 +54,22 @@ # should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that # would let us have both. # -# redirect is left on: `retries=False` would be the obvious spelling, but it means -# Retry(0, read=False), which disables redirect *following* too. The generated -# RESTClientObject still follows redirects, so auth alone would break against a -# deployment that redirects (a proxy forcing https, or path normalisation). -_AUTH_RETRIES = urllib3.Retry(connect=0, read=0, status=0, other=0, redirect=3) +# No redirects either, which is a departure from the generated RESTClientObject: +# - urllib3 clones the timeout per hop instead of drawing down a shared budget, so +# following N redirects costs (1 + N) x auth_timeout_ms on the one round-trip every +# other API thread is blocked behind. +# - the body is re-sent to whatever Location names, with no same-origin restriction, +# so a redirect out of the configured server hands username/password to a third host +# and _do_login would install the token it returns. +# The case this gives up is a deployment that redirects auth (a proxy forcing https, +# path normalisation). Failing loudly is the better answer there: on an http -> https +# redirect the credentials have already gone out in cleartext, so the fix is to pass the +# final auth URL as url=, which _raw_post's error tells the caller to do. +# +# total is set explicitly: it defaults to 10, and leaving it there would contradict the +# "exactly one request" this whole block is for, even though the per-class zeros already +# exhaust first. urllib3 normalises redirect=False to 0. +_AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False) # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a @@ -299,9 +313,8 @@ def _raw_post(self, path: str, body: bytes) -> dict: of using a separate raw HttpClient for AuthManager calls). Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path - for every thread waiting on a refresh, so it must not be able to hang forever, - and it does not retry. See _AUTH_RETRIES for why that trade is deliberate and - why redirects are still followed. + for every thread waiting on a refresh, so it makes exactly one request — no + retry, no redirect. See _AUTH_RETRIES for why both trades are deliberate. """ http = urllib3.PoolManager() response = http.request( @@ -313,7 +326,14 @@ def _raw_post(self, path: str, body: bytes) -> dict: retries=_AUTH_RETRIES, ) if response.status != 200: - raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") + # 3xx arrives here rather than being followed — say so, since the remedy is + # specific and not guessable from the status alone. + hint = ( + "; auth requests do not follow redirects, so pass the final auth URL as url=" + if 300 <= response.status < 400 + else "" + ) + raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}") return json.loads(response.data) def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo": diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index 77810090..cb5a3d73 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -38,11 +38,14 @@ # and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. # -# Applied as Timeout(total=...) so it is the real ceiling: a bare float sets connect and -# read separately and leaves total unbounded. +# Applied as Timeout(total=...) rather than a bare float, which would set connect and +# read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one +# request — no retry, no redirect — total is the whole ceiling; urllib3 gives each +# attempt its own budget, so any allowance there would multiply this number. DEFAULT_AUTH_TIMEOUT_MS = 30_000 -# Retry policy for the raw auth calls, spelled out rather than left to urllib3. +# Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that +# exactly one request goes out and DEFAULT_AUTH_TIMEOUT_MS means what it says. # # No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults # allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are @@ -51,11 +54,22 @@ # should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that # would let us have both. # -# redirect is left on: `retries=False` would be the obvious spelling, but it means -# Retry(0, read=False), which disables redirect *following* too. The generated -# RESTClientObject still follows redirects, so auth alone would break against a -# deployment that redirects (a proxy forcing https, or path normalisation). -_AUTH_RETRIES = urllib3.Retry(connect=0, read=0, status=0, other=0, redirect=3) +# No redirects either, which is a departure from the generated RESTClientObject: +# - urllib3 clones the timeout per hop instead of drawing down a shared budget, so +# following N redirects costs (1 + N) x auth_timeout_ms on the one round-trip every +# other API thread is blocked behind. +# - the body is re-sent to whatever Location names, with no same-origin restriction, +# so a redirect out of the configured server hands username/password to a third host +# and _do_login would install the token it returns. +# The case this gives up is a deployment that redirects auth (a proxy forcing https, +# path normalisation). Failing loudly is the better answer there: on an http -> https +# redirect the credentials have already gone out in cleartext, so the fix is to pass the +# final auth URL as url=, which _raw_post's error tells the caller to do. +# +# total is set explicitly: it defaults to 10, and leaving it there would contradict the +# "exactly one request" this whole block is for, even though the per-class zeros already +# exhaust first. urllib3 normalises redirect=False to 0. +_AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False) # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a @@ -299,9 +313,8 @@ def _raw_post(self, path: str, body: bytes) -> dict: of using a separate raw HttpClient for AuthManager calls). Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path - for every thread waiting on a refresh, so it must not be able to hang forever, - and it does not retry. See _AUTH_RETRIES for why that trade is deliberate and - why redirects are still followed. + for every thread waiting on a refresh, so it makes exactly one request — no + retry, no redirect. See _AUTH_RETRIES for why both trades are deliberate. """ http = urllib3.PoolManager() response = http.request( @@ -313,7 +326,14 @@ def _raw_post(self, path: str, body: bytes) -> dict: retries=_AUTH_RETRIES, ) if response.status != 200: - raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") + # 3xx arrives here rather than being followed — say so, since the remedy is + # specific and not guessable from the status alone. + hint = ( + "; auth requests do not follow redirects, so pass the final auth URL as url=" + if 300 <= response.status < 400 + else "" + ) + raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}") return json.loads(response.data) def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo": diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index 77810090..cb5a3d73 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -38,11 +38,14 @@ # and every API thread now blocks behind an in-flight refresh, so an unresponsive auth # endpoint would otherwise hang the whole process rather than one thread. # -# Applied as Timeout(total=...) so it is the real ceiling: a bare float sets connect and -# read separately and leaves total unbounded. +# Applied as Timeout(total=...) rather than a bare float, which would set connect and +# read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one +# request — no retry, no redirect — total is the whole ceiling; urllib3 gives each +# attempt its own budget, so any allowance there would multiply this number. DEFAULT_AUTH_TIMEOUT_MS = 30_000 -# Retry policy for the raw auth calls, spelled out rather than left to urllib3. +# Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that +# exactly one request goes out and DEFAULT_AUTH_TIMEOUT_MS means what it says. # # No retries: Retry.DEFAULT is total=3, and its connection-error branch never consults # allowed_methods, so this POST would retry and cost 4x the ceiling above. Auth POSTs are @@ -51,11 +54,22 @@ # should retry ThingsboardClient(...) itself, since urllib3 has no global deadline that # would let us have both. # -# redirect is left on: `retries=False` would be the obvious spelling, but it means -# Retry(0, read=False), which disables redirect *following* too. The generated -# RESTClientObject still follows redirects, so auth alone would break against a -# deployment that redirects (a proxy forcing https, or path normalisation). -_AUTH_RETRIES = urllib3.Retry(connect=0, read=0, status=0, other=0, redirect=3) +# No redirects either, which is a departure from the generated RESTClientObject: +# - urllib3 clones the timeout per hop instead of drawing down a shared budget, so +# following N redirects costs (1 + N) x auth_timeout_ms on the one round-trip every +# other API thread is blocked behind. +# - the body is re-sent to whatever Location names, with no same-origin restriction, +# so a redirect out of the configured server hands username/password to a third host +# and _do_login would install the token it returns. +# The case this gives up is a deployment that redirects auth (a proxy forcing https, +# path normalisation). Failing loudly is the better answer there: on an http -> https +# redirect the credentials have already gone out in cleartext, so the fix is to pass the +# final auth URL as url=, which _raw_post's error tells the caller to do. +# +# total is set explicitly: it defaults to 10, and leaving it there would contradict the +# "exactly one request" this whole block is for, even though the per-class zeros already +# exhaust first. urllib3 normalises redirect=False to 0. +_AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False) # Security scheme name and prefixes dictated by the generated configuration.py. # Keep them in one place so a spec regeneration that renames the scheme has a @@ -299,9 +313,8 @@ def _raw_post(self, path: str, body: bytes) -> dict: of using a separate raw HttpClient for AuthManager calls). Bounded by auth_timeout_ms and _AUTH_RETRIES: this call is on the critical path - for every thread waiting on a refresh, so it must not be able to hang forever, - and it does not retry. See _AUTH_RETRIES for why that trade is deliberate and - why redirects are still followed. + for every thread waiting on a refresh, so it makes exactly one request — no + retry, no redirect. See _AUTH_RETRIES for why both trades are deliberate. """ http = urllib3.PoolManager() response = http.request( @@ -313,7 +326,14 @@ def _raw_post(self, path: str, body: bytes) -> dict: retries=_AUTH_RETRIES, ) if response.status != 200: - raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}") + # 3xx arrives here rather than being followed — say so, since the remedy is + # specific and not guessable from the status alone. + hint = ( + "; auth requests do not follow redirects, so pass the final auth URL as url=" + if 300 <= response.status < 400 + else "" + ) + raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}") return json.loads(response.data) def _build_token_info(self, token: str, refresh_token: "str | None") -> "_TokenInfo": diff --git a/scripts/build-packages.sh b/scripts/build-packages.sh index 1ccc041f..5573396f 100755 --- a/scripts/build-packages.sh +++ b/scripts/build-packages.sh @@ -25,9 +25,9 @@ # What it does: # 1. Reads version from root pyproject.toml # 2. Cleans dist/ -# 3. Generates all 3 editions via generate-client.sh all +# 3. Generates each edition in editions.txt via generate-client.sh # 4. For each edition: stamps version, builds wheel + sdist, smoke-tests in clean venv -# 5. Asserts all 3 wheels have the same version in their filenames +# 5. Asserts one wheel per edition, all with the same version in their filenames # 6. Prints summary of built artifacts # # Prerequisites: @@ -200,7 +200,7 @@ for edition in "${EDITIONS[@]}"; do done # --------------------------------------------------------------------------- -# 6. Final verification: assert all 3 wheels have the same version +# 6. Final verification: one wheel per edition, all at the same version # --------------------------------------------------------------------------- info "Final verification" diff --git a/tests/test_auth.py b/tests/test_auth.py index b3821fdb..db764316 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,6 +3,7 @@ Covers AUTH-01 through AUTH-06 requirements. """ +import http.server import threading import time import unittest @@ -256,18 +257,21 @@ def test_does_not_retry(self): self.assertIsInstance(retries, urllib3.Retry) self.assertEqual( - (retries.connect, retries.read, retries.status, retries.other), (0, 0, 0, 0) + (retries.total, retries.connect, retries.read, retries.status, retries.other), + (0, 0, 0, 0, 0), ) - def test_still_follows_redirects(self): - """Not spelled `retries=False`, which would disable redirect following too. + def test_does_not_follow_redirects(self): + """The hop count is load-bearing, so pin the exact value like its neighbour. - The generated RESTClientObject follows redirects, so auth alone breaking against - a redirecting deployment would be a confusing partial failure. + urllib3 gives each hop a fresh timeout budget, so any allowance multiplies the + ceiling; and a followed redirect re-sends username/password to whatever Location + names. See _AUTH_RETRIES. """ retries = _raw_post_kwargs(_AuthManager("http://tb:9090")).get("retries") - self.assertTrue(retries.redirect, "auth requests would stop following redirects") + # urllib3 normalises redirect=False to 0. + self.assertEqual(retries.redirect, 0) def test_timeout_is_configurable(self): """auth_timeout_ms reaches the request, in seconds.""" @@ -288,6 +292,58 @@ def test_non_positive_timeout_rejected(self): _AuthManager("http://tb:9090", auth_timeout_ms=bad) +class _StubAuthServer: + """Local HTTP server answering one canned response, for real _raw_post calls. + + The kwargs tests above prove a setting reaches urllib3; these prove the behaviour + that setting exists for. + """ + + def __init__(self, status, headers=(), body=b""): + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(handler): + handler.send_response(status) + for name, value in headers: + handler.send_header(name, value) + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + def log_message(handler, *args): + pass + + self._server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self._server.server_port}" + + def __enter__(self): + threading.Thread(target=self._server.serve_forever, daemon=True).start() + return self + + def __exit__(self, *exc): + self._server.shutdown() + self._server.server_close() + + +class TestRawPostAgainstAServer(unittest.TestCase): + def test_successful_login_is_parsed(self): + """The happy path works against a real socket, not just a mock.""" + with _StubAuthServer(200, body=b'{"token": "t", "refreshToken": "r"}') as server: + result = _AuthManager(server.url)._raw_post("/api/auth/login", b"{}") + + self.assertEqual(result, {"token": "t", "refreshToken": "r"}) + + def test_redirect_is_not_followed_and_says_why(self): + """A 3xx surfaces as an error naming the remedy, rather than being followed. + + Following it would forward username/password to the redirect target, so this is + the deliberate behaviour rather than a gap — the message has to explain that. + """ + with _StubAuthServer(307, headers=[("Location", "/elsewhere")]) as server: + auth = _AuthManager(server.url) + with self.assertRaisesRegex(RuntimeError, r"HTTP 307.*pass the final auth URL"): + auth._raw_post("/api/auth/login", b"{}") + + class TestConcurrentRefresh(unittest.TestCase): def test_second_thread_waits_for_in_flight_refresh(self): """A thread arriving mid-refresh blocks, then sends the refreshed token. diff --git a/tests/test_client.py b/tests/test_client.py index 6efdc045..ad3f36f6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -218,6 +218,19 @@ def test_empty_auth_arguments_rejected(self): ThingsboardClient(URL, **kwargs) mock_login.assert_not_called() + def test_non_positive_auth_timeout_rejected(self): + """auth_timeout_ms is validated through the public constructor too. + + Rejection happens before the eager login, so a bad value cannot reach the + network — the same guarantee the other cases in this class pin. + """ + for bad in (0, -1): + with self.subTest(auth_timeout_ms=bad): + with patch(_LOGIN_PATCH_TARGET) as mock_login: + with self.assertRaisesRegex(ValueError, "auth_timeout_ms must be positive"): + ThingsboardClient(URL, "user@tb.io", "pass123", auth_timeout_ms=bad) + mock_login.assert_not_called() + def test_empty_argument_message_says_what_to_do(self): """The empty-argument message carries a remedy, like the mutual-exclusion one.""" with self.assertRaisesRegex( diff --git a/tests/test_readme.py b/tests/test_readme.py index 6b29da43..0bf1084f 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -9,7 +9,7 @@ The examples are checked at their source in common/docs/ rather than in one edition's copy: that is the file people edit, and test_common_overlay.py already proves every -/docs/ copy is byte-identical to it, so all three editions are covered here. +/docs/ copy is byte-identical to it, so every edition is covered here. Checks that apply to both documents are parametrized over DOCUMENTS rather than written twice, so a rule added for one cannot silently miss the other. From 4695ed715d8afd60dfc0598d90834947591fbcf9 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 4 Aug 2026 14:25:42 +0300 Subject: [PATCH 17/17] Address fifteenth review: pin raise_on_redirect, tie the remedy to a real Location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - redirect=False and redirect=0 are not interchangeable: Retry.__init__ clears raise_on_redirect only for False, and that is what lets a 3xx come back as a response for _raw_post's status check instead of raising MaxRetryError, so the remedy never reaches the caller. Confirmed both spellings leave .redirect == 0 while raise_on_redirect is False vs True. The comment now says why False is load-bearing, and the test asserts raise_on_redirect — the property that tells the two apart — rather than only the count that cannot. - Gate the redirect hint on response.get_redirect_location() rather than the 3xx range. That is urllib3's own predicate: verified truthy for 301/302/303/307/308 and falsy for 300 and 304, which carry no Location worth chasing. Tighter than matching a Location header, which a 304 can also carry. - The remedy said "pass the final auth URL as url=", but url= is the server base URL — _raw_post appends the path itself, so following it literally would request /api/auth/login/api/auth/login. Now "set url= to the redirect target's base URL instead.", with the terminal period its sibling messages carry. - _StubAuthServer records each requested path, and the redirect test asserts the server saw exactly one. The message alone would still match if a later change forwarded the body once and reported the second reply; verified this fails with ['/api/auth/login', '/elsewhere'] under redirect=1, raise_on_redirect=False. - The ceiling comment claimed a bound it does not have. total is the per-call ceiling; a waiter released by wait_for can sit through a failed refresh *and* the re-login fallback. Measured 2.00s at auth_timeout_ms=1000, with both failures logged. The 2x is inherent to the fallback that recovers an expired refresh token, so the comment states it rather than the fallback being removed. --- ce/tb_ce_client/_auth.py | 30 ++++++++++++++++++++++-------- common/_auth.py | 30 ++++++++++++++++++++++-------- paas/tb_paas_client/_auth.py | 30 ++++++++++++++++++++++-------- pe/tb_pe_client/_auth.py | 30 ++++++++++++++++++++++-------- tests/test_auth.py | 20 ++++++++++++++++++-- 5 files changed, 106 insertions(+), 34 deletions(-) diff --git a/ce/tb_ce_client/_auth.py b/ce/tb_ce_client/_auth.py index cb5a3d73..eb193221 100644 --- a/ce/tb_ce_client/_auth.py +++ b/ce/tb_ce_client/_auth.py @@ -40,8 +40,12 @@ # # Applied as Timeout(total=...) rather than a bare float, which would set connect and # read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one -# request — no retry, no redirect — total is the whole ceiling; urllib3 gives each +# request — no retry, no redirect — total is the per-call ceiling; urllib3 gives each # attempt its own budget, so any allowance there would multiply this number. +# +# A blocked thread can wait twice this: _do_refresh_token falls back to _do_login, which +# is a second call. That is the worst case behind one refresh, and the fallback is what +# recovers an expired refresh token, so the 2x is deliberate rather than a leak. DEFAULT_AUTH_TIMEOUT_MS = 30_000 # Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that @@ -63,12 +67,18 @@ # and _do_login would install the token it returns. # The case this gives up is a deployment that redirects auth (a proxy forcing https, # path normalisation). Failing loudly is the better answer there: on an http -> https -# redirect the credentials have already gone out in cleartext, so the fix is to pass the -# final auth URL as url=, which _raw_post's error tells the caller to do. +# redirect the credentials have already gone out in cleartext, so the fix is to point url= +# at the redirect target's base URL, which _raw_post's error tells the caller to do. # # total is set explicitly: it defaults to 10, and leaving it there would contradict the # "exactly one request" this whole block is for, even though the per-class zeros already -# exhaust first. urllib3 normalises redirect=False to 0. +# exhaust first. +# +# Spelled redirect=False rather than redirect=0, which is not the same thing: Retry +# normalises False to 0 *and* clears raise_on_redirect, and that is what lets a 3xx come +# back as a response for the status check below to report. With redirect=0 the same reply +# raises MaxRetryError("too many redirects") instead and the remedy never reaches the +# caller. Both spellings leave .redirect == 0, so only raise_on_redirect tells them apart. _AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False) # Security scheme name and prefixes dictated by the generated configuration.py. @@ -326,11 +336,15 @@ def _raw_post(self, path: str, body: bytes) -> dict: retries=_AUTH_RETRIES, ) if response.status != 200: - # 3xx arrives here rather than being followed — say so, since the remedy is - # specific and not guessable from the status alone. + # A redirect arrives here rather than being followed — say so, since the + # remedy is specific and not guessable from the status alone. Gated on + # get_redirect_location() rather than the 3xx range because that is the + # predicate urllib3 itself would have followed: it covers 301/302/303/307/308 + # and excludes 300 and 304, which carry no Location worth chasing. hint = ( - "; auth requests do not follow redirects, so pass the final auth URL as url=" - if 300 <= response.status < 400 + "; auth requests do not follow redirects, so set url= to the redirect " + "target's base URL instead." + if response.get_redirect_location() else "" ) raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}") diff --git a/common/_auth.py b/common/_auth.py index cb5a3d73..eb193221 100644 --- a/common/_auth.py +++ b/common/_auth.py @@ -40,8 +40,12 @@ # # Applied as Timeout(total=...) rather than a bare float, which would set connect and # read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one -# request — no retry, no redirect — total is the whole ceiling; urllib3 gives each +# request — no retry, no redirect — total is the per-call ceiling; urllib3 gives each # attempt its own budget, so any allowance there would multiply this number. +# +# A blocked thread can wait twice this: _do_refresh_token falls back to _do_login, which +# is a second call. That is the worst case behind one refresh, and the fallback is what +# recovers an expired refresh token, so the 2x is deliberate rather than a leak. DEFAULT_AUTH_TIMEOUT_MS = 30_000 # Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that @@ -63,12 +67,18 @@ # and _do_login would install the token it returns. # The case this gives up is a deployment that redirects auth (a proxy forcing https, # path normalisation). Failing loudly is the better answer there: on an http -> https -# redirect the credentials have already gone out in cleartext, so the fix is to pass the -# final auth URL as url=, which _raw_post's error tells the caller to do. +# redirect the credentials have already gone out in cleartext, so the fix is to point url= +# at the redirect target's base URL, which _raw_post's error tells the caller to do. # # total is set explicitly: it defaults to 10, and leaving it there would contradict the # "exactly one request" this whole block is for, even though the per-class zeros already -# exhaust first. urllib3 normalises redirect=False to 0. +# exhaust first. +# +# Spelled redirect=False rather than redirect=0, which is not the same thing: Retry +# normalises False to 0 *and* clears raise_on_redirect, and that is what lets a 3xx come +# back as a response for the status check below to report. With redirect=0 the same reply +# raises MaxRetryError("too many redirects") instead and the remedy never reaches the +# caller. Both spellings leave .redirect == 0, so only raise_on_redirect tells them apart. _AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False) # Security scheme name and prefixes dictated by the generated configuration.py. @@ -326,11 +336,15 @@ def _raw_post(self, path: str, body: bytes) -> dict: retries=_AUTH_RETRIES, ) if response.status != 200: - # 3xx arrives here rather than being followed — say so, since the remedy is - # specific and not guessable from the status alone. + # A redirect arrives here rather than being followed — say so, since the + # remedy is specific and not guessable from the status alone. Gated on + # get_redirect_location() rather than the 3xx range because that is the + # predicate urllib3 itself would have followed: it covers 301/302/303/307/308 + # and excludes 300 and 304, which carry no Location worth chasing. hint = ( - "; auth requests do not follow redirects, so pass the final auth URL as url=" - if 300 <= response.status < 400 + "; auth requests do not follow redirects, so set url= to the redirect " + "target's base URL instead." + if response.get_redirect_location() else "" ) raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}") diff --git a/paas/tb_paas_client/_auth.py b/paas/tb_paas_client/_auth.py index cb5a3d73..eb193221 100644 --- a/paas/tb_paas_client/_auth.py +++ b/paas/tb_paas_client/_auth.py @@ -40,8 +40,12 @@ # # Applied as Timeout(total=...) rather than a bare float, which would set connect and # read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one -# request — no retry, no redirect — total is the whole ceiling; urllib3 gives each +# request — no retry, no redirect — total is the per-call ceiling; urllib3 gives each # attempt its own budget, so any allowance there would multiply this number. +# +# A blocked thread can wait twice this: _do_refresh_token falls back to _do_login, which +# is a second call. That is the worst case behind one refresh, and the fallback is what +# recovers an expired refresh token, so the 2x is deliberate rather than a leak. DEFAULT_AUTH_TIMEOUT_MS = 30_000 # Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that @@ -63,12 +67,18 @@ # and _do_login would install the token it returns. # The case this gives up is a deployment that redirects auth (a proxy forcing https, # path normalisation). Failing loudly is the better answer there: on an http -> https -# redirect the credentials have already gone out in cleartext, so the fix is to pass the -# final auth URL as url=, which _raw_post's error tells the caller to do. +# redirect the credentials have already gone out in cleartext, so the fix is to point url= +# at the redirect target's base URL, which _raw_post's error tells the caller to do. # # total is set explicitly: it defaults to 10, and leaving it there would contradict the # "exactly one request" this whole block is for, even though the per-class zeros already -# exhaust first. urllib3 normalises redirect=False to 0. +# exhaust first. +# +# Spelled redirect=False rather than redirect=0, which is not the same thing: Retry +# normalises False to 0 *and* clears raise_on_redirect, and that is what lets a 3xx come +# back as a response for the status check below to report. With redirect=0 the same reply +# raises MaxRetryError("too many redirects") instead and the remedy never reaches the +# caller. Both spellings leave .redirect == 0, so only raise_on_redirect tells them apart. _AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False) # Security scheme name and prefixes dictated by the generated configuration.py. @@ -326,11 +336,15 @@ def _raw_post(self, path: str, body: bytes) -> dict: retries=_AUTH_RETRIES, ) if response.status != 200: - # 3xx arrives here rather than being followed — say so, since the remedy is - # specific and not guessable from the status alone. + # A redirect arrives here rather than being followed — say so, since the + # remedy is specific and not guessable from the status alone. Gated on + # get_redirect_location() rather than the 3xx range because that is the + # predicate urllib3 itself would have followed: it covers 301/302/303/307/308 + # and excludes 300 and 304, which carry no Location worth chasing. hint = ( - "; auth requests do not follow redirects, so pass the final auth URL as url=" - if 300 <= response.status < 400 + "; auth requests do not follow redirects, so set url= to the redirect " + "target's base URL instead." + if response.get_redirect_location() else "" ) raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}") diff --git a/pe/tb_pe_client/_auth.py b/pe/tb_pe_client/_auth.py index cb5a3d73..eb193221 100644 --- a/pe/tb_pe_client/_auth.py +++ b/pe/tb_pe_client/_auth.py @@ -40,8 +40,12 @@ # # Applied as Timeout(total=...) rather than a bare float, which would set connect and # read separately and leave total unbounded. Because _AUTH_RETRIES makes exactly one -# request — no retry, no redirect — total is the whole ceiling; urllib3 gives each +# request — no retry, no redirect — total is the per-call ceiling; urllib3 gives each # attempt its own budget, so any allowance there would multiply this number. +# +# A blocked thread can wait twice this: _do_refresh_token falls back to _do_login, which +# is a second call. That is the worst case behind one refresh, and the fallback is what +# recovers an expired refresh token, so the 2x is deliberate rather than a leak. DEFAULT_AUTH_TIMEOUT_MS = 30_000 # Retry policy for the raw auth calls, spelled out rather than left to urllib3, so that @@ -63,12 +67,18 @@ # and _do_login would install the token it returns. # The case this gives up is a deployment that redirects auth (a proxy forcing https, # path normalisation). Failing loudly is the better answer there: on an http -> https -# redirect the credentials have already gone out in cleartext, so the fix is to pass the -# final auth URL as url=, which _raw_post's error tells the caller to do. +# redirect the credentials have already gone out in cleartext, so the fix is to point url= +# at the redirect target's base URL, which _raw_post's error tells the caller to do. # # total is set explicitly: it defaults to 10, and leaving it there would contradict the # "exactly one request" this whole block is for, even though the per-class zeros already -# exhaust first. urllib3 normalises redirect=False to 0. +# exhaust first. +# +# Spelled redirect=False rather than redirect=0, which is not the same thing: Retry +# normalises False to 0 *and* clears raise_on_redirect, and that is what lets a 3xx come +# back as a response for the status check below to report. With redirect=0 the same reply +# raises MaxRetryError("too many redirects") instead and the remedy never reaches the +# caller. Both spellings leave .redirect == 0, so only raise_on_redirect tells them apart. _AUTH_RETRIES = urllib3.Retry(total=0, connect=0, read=0, status=0, other=0, redirect=False) # Security scheme name and prefixes dictated by the generated configuration.py. @@ -326,11 +336,15 @@ def _raw_post(self, path: str, body: bytes) -> dict: retries=_AUTH_RETRIES, ) if response.status != 200: - # 3xx arrives here rather than being followed — say so, since the remedy is - # specific and not guessable from the status alone. + # A redirect arrives here rather than being followed — say so, since the + # remedy is specific and not guessable from the status alone. Gated on + # get_redirect_location() rather than the 3xx range because that is the + # predicate urllib3 itself would have followed: it covers 301/302/303/307/308 + # and excludes 300 and 304, which carry no Location worth chasing. hint = ( - "; auth requests do not follow redirects, so pass the final auth URL as url=" - if 300 <= response.status < 400 + "; auth requests do not follow redirects, so set url= to the redirect " + "target's base URL instead." + if response.get_redirect_location() else "" ) raise RuntimeError(f"Auth request to {path} returned HTTP {response.status}{hint}") diff --git a/tests/test_auth.py b/tests/test_auth.py index db764316..418afaa2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -272,6 +272,11 @@ def test_does_not_follow_redirects(self): # urllib3 normalises redirect=False to 0. self.assertEqual(retries.redirect, 0) + # The half that redirect=0 would not give: without raise_on_redirect cleared, a + # 3xx raises MaxRetryError instead of reaching _raw_post's status check, and the + # remedy in that message never reaches the caller. .redirect alone cannot tell + # the two spellings apart, so this is the assertion that pins redirect=False. + self.assertIs(retries.raise_on_redirect, False) def test_timeout_is_configurable(self): """auth_timeout_ms reaches the request, in seconds.""" @@ -300,8 +305,14 @@ class _StubAuthServer: """ def __init__(self, status, headers=(), body=b""): + # Every path the server is asked for, so a test can assert on what was *not* + # requested — a followed redirect shows up here as a second entry. + self.requests = [] + requests = self.requests + class Handler(http.server.BaseHTTPRequestHandler): def do_POST(handler): + requests.append(handler.path) handler.send_response(status) for name, value in headers: handler.send_header(name, value) @@ -333,16 +344,21 @@ def test_successful_login_is_parsed(self): self.assertEqual(result, {"token": "t", "refreshToken": "r"}) def test_redirect_is_not_followed_and_says_why(self): - """A 3xx surfaces as an error naming the remedy, rather than being followed. + """A redirect surfaces as an error naming the remedy, rather than being followed. Following it would forward username/password to the redirect target, so this is the deliberate behaviour rather than a gap — the message has to explain that. + + The request count is the load-bearing assertion: the message alone would still + match if a later change forwarded the body once and reported the second reply. """ with _StubAuthServer(307, headers=[("Location", "/elsewhere")]) as server: auth = _AuthManager(server.url) - with self.assertRaisesRegex(RuntimeError, r"HTTP 307.*pass the final auth URL"): + with self.assertRaisesRegex(RuntimeError, r"HTTP 307.*set url= to the redirect"): auth._raw_post("/api/auth/login", b"{}") + self.assertEqual(server.requests, ["/api/auth/login"], "the redirect was followed") + class TestConcurrentRefresh(unittest.TestCase): def test_second_thread_waits_for_in_flight_refresh(self):