From 2dc8456b2c4278b0d0ddc3b19e38118da967e087 Mon Sep 17 00:00:00 2001 From: Twan Nooitmeer Date: Thu, 6 Aug 2026 00:35:51 +0200 Subject: [PATCH 1/3] Allow overriding the TR app version and User-Agent from the environment The v2 login identifies itself as Trade Republic's own web frontend, using two values that describe someone else's deployment: APP_VERSION, sent as X-TR-App-Version, and the browser User-Agent. Trade Republic can invalidate either one at any moment. A frontend release makes APP_VERSION stale, at which point every login returns 426 CLIENT_VERSION_OUTDATED, which is exactly what #250 was; a change to their bot filtering can make the User-Agent the thing being rejected. Neither failure needs a code change to fix. It needs a different string. But because both are baked into the module, a user locked out today has to wait for a release, and the maintainers have to cut one under time pressure. PYTR_TR_APP_VERSION and PYTR_TR_USER_AGENT override them, so the fix becomes an exported variable and the release can happen at its own pace. An unset or empty variable keeps the built-in default, so an empty assignment can never send an empty header. The User-Agent override is applied to a per-instance copy of _default_headers. That dict is a class attribute and was previously handed straight to requests.Session, which mutates what it is given, so copying it also stops one instance's session from writing into state shared by all of them. X-TR-Device-Info stays consistent with whatever User-Agent is in force: browserVersion is scraped from it, and a non-Chrome override leaves that field empty rather than reporting a version the browser never claimed. The frontend omits fields it cannot fill too, and the server accepts their absence. Tests cover both overrides, the empty-value fallback, the absence of cross-instance leakage, and the device-info consistency. README documents both variables, where to read current values from the live frontend, and asks users who need one to open an issue so the default gets updated. Co-Authored-By: Claude Opus 5 --- README.md | 20 +++++++++++++ pytr/api.py | 32 +++++++++++++++++--- tests/test_api_urls.py | 68 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index adcdb8d9..b07dd5b3 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,26 @@ procedure will log you out from your mobile device. If no arguments are supplied pytr will look for them in the file `~/.pytr/credentials` (the first line must contain the phone number, the second line the pin). If the file doesn't exist pytr will ask for for the phone number and pin. +### If web login suddenly stops working + +The web login identifies itself to Trade Republic as their own web frontend, using a build version and a browser +`User-Agent` that are pinned in `pytr`. Trade Republic can invalidate either at any time, and when they do, login +fails for everyone until a new release goes out. Two environment variables let you fix it yourself in the meantime: + +| Variable | Overrides | Use it when | +|---|---|---| +| `PYTR_TR_APP_VERSION` | The frontend build version sent as `X-TR-App-Version` | Login fails with `426 CLIENT_VERSION_OUTDATED` | +| `PYTR_TR_USER_AGENT` | The `User-Agent` sent on every request | Login is rejected or challenged in a way that looks like bot filtering | + +```sh + PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2 + PYTR_TR_USER_AGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36' pytr login +``` + +Read the current values off [app.traderepublic.com](https://app.traderepublic.com/) in your browser's dev tools, on the +network request to `/api/v2/auth/web/login`. Leaving a variable unset, or setting it to an empty string, keeps the +built-in default. If you need one of these, please also open an issue so the default can be updated for everyone. + ## Development ### Setting Up a Development Environment diff --git a/pytr/api.py b/pytr/api.py index c17c5f66..f9c48178 100644 --- a/pytr/api.py +++ b/pytr/api.py @@ -86,11 +86,29 @@ # The web frontend's API client identifies itself with this platform on all v2 login calls. WEB_PLATFORM = "web-pro" +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +) + +# Both values above describe someone else's deployment, and Trade Republic can +# invalidate either one at any moment: a frontend release makes APP_VERSION stale +# (the endpoints then answer 426 CLIENT_VERSION_OUTDATED, which is what #250 was), +# and a change to their bot filtering can make the User-Agent the thing being +# rejected. Neither failure needs a code change to fix, only a different string, so +# both are readable from the environment. That turns "wait for a pytr release" into +# "export a variable" for a user who is locked out today. +# +# PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2 +# PYTR_TR_USER_AGENT='Mozilla/5.0 ... Chrome/149.0.0.0 Safari/537.36' pytr login +# +# An unset or empty variable keeps the built-in default, so an empty assignment can +# never send an empty header. +ENV_APP_VERSION = "PYTR_TR_APP_VERSION" +ENV_USER_AGENT = "PYTR_TR_USER_AGENT" + class TradeRepublicApi: - _default_headers = { - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" - } + _default_headers = {"User-Agent": DEFAULT_USER_AGENT} _host = "https://api.traderepublic.com" _waf_login_url = "https://app.traderepublic.com/login" @@ -142,6 +160,12 @@ def __init__( self._cookies_file = pathlib.Path(cookies_file) if cookies_file else BASE_DIR / f"cookies.{self.phone_no}.txt" self._websession = requests.Session() + # Copy before overriding: `_default_headers` is a class attribute, and it is + # handed straight to the session below, which mutates what it is given. + self._default_headers = dict(self._default_headers) + user_agent = os.environ.get(ENV_USER_AGENT) + if user_agent: + self._default_headers["User-Agent"] = user_agent self._websession.headers = self._default_headers if self._save_cookies: self._websession.cookies = MozillaCookieJar(self._cookies_file) @@ -316,7 +340,7 @@ def _login_headers(self): self._device_info = base64.b64encode(json.dumps(device).encode()).decode() return { "X-TR-Device-Info": self._device_info, - "X-TR-App-Version": APP_VERSION, + "X-TR-App-Version": os.environ.get(ENV_APP_VERSION) or APP_VERSION, "X-Tr-Platform": WEB_PLATFORM, "Accept-Language": self._locale, } diff --git a/tests/test_api_urls.py b/tests/test_api_urls.py index 07c17e57..043b666e 100644 --- a/tests/test_api_urls.py +++ b/tests/test_api_urls.py @@ -8,7 +8,13 @@ import pytest import requests -from pytr.api import TradeRepublicApi +from pytr.api import ( + APP_VERSION, + DEFAULT_USER_AGENT, + ENV_APP_VERSION, + ENV_USER_AGENT, + TradeRepublicApi, +) LOGIN = "https://api.traderepublic.com/api/v2/auth/web/login" PROCESS = "https://api.traderepublic.com/api/v2/auth/web/login/processes/pid-1" @@ -261,6 +267,66 @@ def test_app_version_and_platform_come_from_the_web_frontend(): assert headers["X-Tr-Platform"] == "web-pro" +# --- environment overrides ----------------------------------------------------------- + +CHROME_149 = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + + +def _real_session_api(): + """An instance keeping its real session, so constructor-set headers survive.""" + return TradeRepublicApi(phone_no="+490000000000", pin="0000", waf_token=None, use_v2_login=True) + + +def test_app_version_can_be_overridden_from_the_environment(monkeypatch): + """A frontend release makes the built-in stale; 426 must be fixable without a release.""" + monkeypatch.setenv(ENV_APP_VERSION, "2.9999.1") + + assert _api([])._login_headers()["X-TR-App-Version"] == "2.9999.1" + + +def test_user_agent_can_be_overridden_from_the_environment(monkeypatch): + monkeypatch.setenv(ENV_USER_AGENT, CHROME_149) + + assert _real_session_api()._websession.headers["User-Agent"] == CHROME_149 + + +def test_an_empty_override_keeps_the_built_in_default(monkeypatch): + """An empty assignment must not send an empty version or an empty User-Agent.""" + monkeypatch.setenv(ENV_APP_VERSION, "") + monkeypatch.setenv(ENV_USER_AGENT, "") + + assert _api([])._login_headers()["X-TR-App-Version"] == APP_VERSION + assert _real_session_api()._websession.headers["User-Agent"] == DEFAULT_USER_AGENT + + +def test_overriding_the_user_agent_does_not_leak_into_other_instances(monkeypatch): + """The default lives on the class; overriding it must stay on the instance.""" + monkeypatch.setenv(ENV_USER_AGENT, CHROME_149) + _real_session_api() + monkeypatch.delenv(ENV_USER_AGENT) + + assert TradeRepublicApi._default_headers["User-Agent"] == DEFAULT_USER_AGENT + assert _real_session_api()._websession.headers["User-Agent"] == DEFAULT_USER_AGENT + + +def test_device_info_follows_the_overridden_user_agent(monkeypatch): + """browserVersion is scraped from the User-Agent; the two must not drift apart.""" + monkeypatch.setenv(ENV_USER_AGENT, CHROME_149) + + device = jsonlib.loads(base64.b64decode(_real_session_api()._login_headers()["X-TR-Device-Info"])) + + assert device["browserVersion"] == "149.0.0.0" + + +def test_a_non_chrome_user_agent_leaves_the_browser_version_empty(monkeypatch): + """The frontend omits what the browser does not provide, and TR accepts that.""" + monkeypatch.setenv(ENV_USER_AGENT, "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0") + + device = jsonlib.loads(base64.b64decode(_real_session_api()._login_headers()["X-TR-Device-Info"])) + + assert device["browserVersion"] == "" + + # --- endpoints that must NOT move ---------------------------------------------------- From fbaf091a2e3d0f11b3e6c2811e4f04512fcb2e57 Mon Sep 17 00:00:00 2001 From: Twan Nooitmeer Date: Fri, 7 Aug 2026 22:17:23 +0200 Subject: [PATCH 2/3] Add a WEB_PLATFORM override and regenerate the README table of contents Addresses the review on #382. PYTR_TR_PLATFORM overrides WEB_PLATFORM, the same way the other two work: an unset or empty value keeps the built-in "web-pro", so an empty assignment can never send an empty X-Tr-Platform. It is the least likely of the three to move, but it is the same class of value, pinned to someone else's deployment and unfixable without a release. The failing check was the README one, not a test. The repository generates its table of contents with mksync and CI diffs the committed file against a fresh run. The new "If web login suddenly stops working" heading was missing its entry, so the two differed. Regenerated with the documented command, `uvx mksync@0.1.5 -i README.md`, which adds the entry and nothing else. Also documents PYTR_TR_PLATFORM in the same table. Verified against all five CI steps rather than assuming: pytest (226), ruff 0.9.6 check and format, mypy, and the mksync diff. Note that the CI diff command uses `sed -z`, which does not exist on macOS, so running it verbatim there silently compares two empty streams and always passes; the check above used a portable equivalent and was confirmed to fail when the table-of-contents entry is removed. Co-Authored-By: Claude Opus 5 --- README.md | 10 +++++++--- pytr/api.py | 15 +++++++++------ tests/test_api_urls.py | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 88fc081e..1fed3a07 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ __Table of Contents__ * [Usage](#usage) * [Authentication](#authentication) * [Web login](#web-login) + * [If web login suddenly stops working](#if-web-login-suddenly-stops-working) * [Development](#development) * [Setting Up a Development Environment](#setting-up-a-development-environment) * [Linting and Code Formatting](#linting-and-code-formatting) @@ -118,18 +119,21 @@ running `pytr`. ### If web login suddenly stops working -The web login identifies itself to Trade Republic as their own web frontend, using a build version and a browser -`User-Agent` that are pinned in `pytr`. Trade Republic can invalidate either at any time, and when they do, login -fails for everyone until a new release goes out. Two environment variables let you fix it yourself in the meantime: +The web login identifies itself to Trade Republic as their own web frontend, using a build version, a platform name +and a browser `User-Agent` that are pinned in `pytr`. Trade Republic can invalidate any of them at any time, and when +they do, login fails for everyone until a new release goes out. Three environment variables let you fix it yourself +in the meantime: | Variable | Overrides | Use it when | |---|---|---| | `PYTR_TR_APP_VERSION` | The frontend build version sent as `X-TR-App-Version` | Login fails with `426 CLIENT_VERSION_OUTDATED` | | `PYTR_TR_USER_AGENT` | The `User-Agent` sent on every request | Login is rejected or challenged in a way that looks like bot filtering | +| `PYTR_TR_PLATFORM` | The platform name sent as `X-Tr-Platform` | Login fails with a missing or invalid header error naming the platform | ```sh PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2 PYTR_TR_USER_AGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36' pytr login + PYTR_TR_PLATFORM=web pytr login --v2 ``` Read the current values off [app.traderepublic.com](https://app.traderepublic.com/) in your browser's dev tools, on the diff --git a/pytr/api.py b/pytr/api.py index a96005e7..9894eaee 100644 --- a/pytr/api.py +++ b/pytr/api.py @@ -90,21 +90,24 @@ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" ) -# Both values above describe someone else's deployment, and Trade Republic can -# invalidate either one at any moment: a frontend release makes APP_VERSION stale +# All three values above describe someone else's deployment, and Trade Republic can +# invalidate any of them at any moment: a frontend release makes APP_VERSION stale # (the endpoints then answer 426 CLIENT_VERSION_OUTDATED, which is what #250 was), -# and a change to their bot filtering can make the User-Agent the thing being -# rejected. Neither failure needs a code change to fix, only a different string, so -# both are readable from the environment. That turns "wait for a pytr release" into +# a change to their bot filtering can make the User-Agent the thing being rejected, +# and WEB_PLATFORM is whatever string their API client happens to be configured with. +# None of those failures needs a code change to fix, only a different string, so all +# three are readable from the environment. That turns "wait for a pytr release" into # "export a variable" for a user who is locked out today. # # PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2 # PYTR_TR_USER_AGENT='Mozilla/5.0 ... Chrome/149.0.0.0 Safari/537.36' pytr login +# PYTR_TR_PLATFORM=web pytr login --v2 # # An unset or empty variable keeps the built-in default, so an empty assignment can # never send an empty header. ENV_APP_VERSION = "PYTR_TR_APP_VERSION" ENV_USER_AGENT = "PYTR_TR_USER_AGENT" +ENV_PLATFORM = "PYTR_TR_PLATFORM" class TradeRepublicApi: @@ -349,7 +352,7 @@ def _login_headers(self): return { "X-TR-Device-Info": self._device_info, "X-TR-App-Version": os.environ.get(ENV_APP_VERSION) or APP_VERSION, - "X-Tr-Platform": WEB_PLATFORM, + "X-Tr-Platform": os.environ.get(ENV_PLATFORM) or WEB_PLATFORM, "Accept-Language": self._locale, } diff --git a/tests/test_api_urls.py b/tests/test_api_urls.py index 043b666e..9de36979 100644 --- a/tests/test_api_urls.py +++ b/tests/test_api_urls.py @@ -12,7 +12,9 @@ APP_VERSION, DEFAULT_USER_AGENT, ENV_APP_VERSION, + ENV_PLATFORM, ENV_USER_AGENT, + WEB_PLATFORM, TradeRepublicApi, ) @@ -290,12 +292,21 @@ def test_user_agent_can_be_overridden_from_the_environment(monkeypatch): assert _real_session_api()._websession.headers["User-Agent"] == CHROME_149 +def test_platform_can_be_overridden_from_the_environment(monkeypatch): + monkeypatch.setenv(ENV_PLATFORM, "web") + + assert _api([])._login_headers()["X-Tr-Platform"] == "web" + + def test_an_empty_override_keeps_the_built_in_default(monkeypatch): - """An empty assignment must not send an empty version or an empty User-Agent.""" + """An empty assignment must not send an empty header for any of the three.""" monkeypatch.setenv(ENV_APP_VERSION, "") monkeypatch.setenv(ENV_USER_AGENT, "") + monkeypatch.setenv(ENV_PLATFORM, "") - assert _api([])._login_headers()["X-TR-App-Version"] == APP_VERSION + headers = _api([])._login_headers() + assert headers["X-TR-App-Version"] == APP_VERSION + assert headers["X-Tr-Platform"] == WEB_PLATFORM assert _real_session_api()._websession.headers["User-Agent"] == DEFAULT_USER_AGENT From a8f8cbaa335f24807a2479ceec9ad55515d70f74 Mon Sep 17 00:00:00 2001 From: Twan Nooitmeer Date: Wed, 12 Aug 2026 22:39:02 +0200 Subject: [PATCH 3/3] Apply review feedback on the environment overrides * DEFAULT_USER_AGENT is now USER_AGENT, in line with APP_VERSION and WEB_PLATFORM next to it. * The ENV_ constants are sorted APP_VERSION, PLATFORM, USER_AGENT, and so is the import in the test module. * The block comment is cut to the point, since the README already explains when to reach for which variable. * _default_headers reads ENV_USER_AGENT directly, and the clone in __init__ is gone. On the clone: there was no reason to keep it. It existed because __init__ wrote into _default_headers, and writing into a class attribute would have leaked the override into every other instance. Reading the environment where the attribute is defined removes the write, and with it the need to copy. The justification originally given for the copy, that requests mutates the header dict it is handed, was wrong: requests merges headers into a new dict per request and leaves the session's own dict alone. One consequence worth naming: the User-Agent is now resolved when the class body executes, so setting os.environ after importing pytr.api no longer affects it, where APP_VERSION and WEB_PLATFORM are still read per call. For the CLI the two are indistinguishable, since the environment is set before the process starts. The tests reimport the module to observe it, in a fixture that restores the module afterwards. Verified against all five CI steps: pytest (228), ruff 0.9.6 check and format, mypy, and the mksync diff. The three User-Agent tests were confirmed to fail when the override is removed, so the reimport does not hide them. Co-Authored-By: Claude Opus 5 --- pytr/api.py | 37 ++++++-------------- tests/test_api_urls.py | 79 ++++++++++++++++++++++++------------------ 2 files changed, 55 insertions(+), 61 deletions(-) diff --git a/pytr/api.py b/pytr/api.py index 9894eaee..16ce660c 100644 --- a/pytr/api.py +++ b/pytr/api.py @@ -86,32 +86,21 @@ # The web frontend's API client identifies itself with this platform on all v2 login calls. WEB_PLATFORM = "web-pro" -DEFAULT_USER_AGENT = ( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" -) - -# All three values above describe someone else's deployment, and Trade Republic can -# invalidate any of them at any moment: a frontend release makes APP_VERSION stale -# (the endpoints then answer 426 CLIENT_VERSION_OUTDATED, which is what #250 was), -# a change to their bot filtering can make the User-Agent the thing being rejected, -# and WEB_PLATFORM is whatever string their API client happens to be configured with. -# None of those failures needs a code change to fix, only a different string, so all -# three are readable from the environment. That turns "wait for a pytr release" into -# "export a variable" for a user who is locked out today. -# -# PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2 -# PYTR_TR_USER_AGENT='Mozilla/5.0 ... Chrome/149.0.0.0 Safari/537.36' pytr login -# PYTR_TR_PLATFORM=web pytr login --v2 -# -# An unset or empty variable keeps the built-in default, so an empty assignment can -# never send an empty header. +# Sent on every request. Trade Republic can start rejecting a stale one as bot traffic. +USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" + +# Trade Republic can invalidate any of the three values above at any time, and none of +# those failures needs a code change to fix, only a different string. Overriding them +# from the environment turns "wait for a pytr release" into "export a variable" for a +# user who is locked out today. See the README for when to reach for which. An unset or +# empty variable keeps the built-in default, so an empty assignment sends no empty header. ENV_APP_VERSION = "PYTR_TR_APP_VERSION" -ENV_USER_AGENT = "PYTR_TR_USER_AGENT" ENV_PLATFORM = "PYTR_TR_PLATFORM" +ENV_USER_AGENT = "PYTR_TR_USER_AGENT" class TradeRepublicApi: - _default_headers = {"User-Agent": DEFAULT_USER_AGENT} + _default_headers = {"User-Agent": os.environ.get(ENV_USER_AGENT) or USER_AGENT} _host = "https://api.traderepublic.com" _waf_login_url = "https://app.traderepublic.com/login" @@ -165,12 +154,6 @@ def __init__( self._cookies_file = pathlib.Path(cookies_file) if cookies_file else BASE_DIR / f"cookies.{self.phone_no}.txt" self._websession = requests.Session() - # Copy before overriding: `_default_headers` is a class attribute, and it is - # handed straight to the session below, which mutates what it is given. - self._default_headers = dict(self._default_headers) - user_agent = os.environ.get(ENV_USER_AGENT) - if user_agent: - self._default_headers["User-Agent"] = user_agent self._websession.headers = self._default_headers if self._save_cookies: self._websession.cookies = MozillaCookieJar(self._cookies_file) diff --git a/tests/test_api_urls.py b/tests/test_api_urls.py index 9de36979..cd097233 100644 --- a/tests/test_api_urls.py +++ b/tests/test_api_urls.py @@ -1,6 +1,7 @@ """Pin the web login endpoints, their required headers and the login process state machine.""" import base64 +import importlib import json as jsonlib import re from typing import Any @@ -8,12 +9,13 @@ import pytest import requests +import pytr.api from pytr.api import ( APP_VERSION, - DEFAULT_USER_AGENT, ENV_APP_VERSION, ENV_PLATFORM, ENV_USER_AGENT, + USER_AGENT, WEB_PLATFORM, TradeRepublicApi, ) @@ -274,9 +276,34 @@ def test_app_version_and_platform_come_from_the_web_frontend(): CHROME_149 = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" -def _real_session_api(): - """An instance keeping its real session, so constructor-set headers survive.""" - return TradeRepublicApi(phone_no="+490000000000", pin="0000", waf_token=None, use_v2_login=True) +FIREFOX_128 = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" + + +@pytest.fixture +def reimported_with(monkeypatch): + """pytr.api re-imported under a patched environment. + + X-TR-App-Version and X-Tr-Platform are read per call, so those overrides need + nothing special. The User-Agent is baked into _default_headers when the class + body runs, so seeing a different one means importing the module again. + """ + + def _load(**environment): + for name, value in environment.items(): + monkeypatch.setenv(name, value) + return importlib.reload(pytr.api) + + yield _load + monkeypatch.undo() + importlib.reload(pytr.api) + + +def _api_of(module): + return module.TradeRepublicApi(phone_no="+490000000000", pin="0000", waf_token=None, use_v2_login=True) + + +def _device_info_of(module): + return jsonlib.loads(base64.b64decode(_api_of(module)._login_headers()["X-TR-Device-Info"])) def test_app_version_can_be_overridden_from_the_environment(monkeypatch): @@ -286,56 +313,40 @@ def test_app_version_can_be_overridden_from_the_environment(monkeypatch): assert _api([])._login_headers()["X-TR-App-Version"] == "2.9999.1" -def test_user_agent_can_be_overridden_from_the_environment(monkeypatch): - monkeypatch.setenv(ENV_USER_AGENT, CHROME_149) - - assert _real_session_api()._websession.headers["User-Agent"] == CHROME_149 - - def test_platform_can_be_overridden_from_the_environment(monkeypatch): monkeypatch.setenv(ENV_PLATFORM, "web") assert _api([])._login_headers()["X-Tr-Platform"] == "web" -def test_an_empty_override_keeps_the_built_in_default(monkeypatch): +def test_user_agent_can_be_overridden_from_the_environment(reimported_with): + module = reimported_with(**{ENV_USER_AGENT: CHROME_149}) + + assert module.TradeRepublicApi._default_headers["User-Agent"] == CHROME_149 + assert _api_of(module)._websession.headers["User-Agent"] == CHROME_149 + + +def test_an_empty_override_keeps_the_built_in_default(monkeypatch, reimported_with): """An empty assignment must not send an empty header for any of the three.""" monkeypatch.setenv(ENV_APP_VERSION, "") - monkeypatch.setenv(ENV_USER_AGENT, "") monkeypatch.setenv(ENV_PLATFORM, "") headers = _api([])._login_headers() assert headers["X-TR-App-Version"] == APP_VERSION assert headers["X-Tr-Platform"] == WEB_PLATFORM - assert _real_session_api()._websession.headers["User-Agent"] == DEFAULT_USER_AGENT - -def test_overriding_the_user_agent_does_not_leak_into_other_instances(monkeypatch): - """The default lives on the class; overriding it must stay on the instance.""" - monkeypatch.setenv(ENV_USER_AGENT, CHROME_149) - _real_session_api() - monkeypatch.delenv(ENV_USER_AGENT) + module = reimported_with(**{ENV_USER_AGENT: ""}) + assert module.TradeRepublicApi._default_headers["User-Agent"] == USER_AGENT - assert TradeRepublicApi._default_headers["User-Agent"] == DEFAULT_USER_AGENT - assert _real_session_api()._websession.headers["User-Agent"] == DEFAULT_USER_AGENT - -def test_device_info_follows_the_overridden_user_agent(monkeypatch): +def test_device_info_follows_the_overridden_user_agent(reimported_with): """browserVersion is scraped from the User-Agent; the two must not drift apart.""" - monkeypatch.setenv(ENV_USER_AGENT, CHROME_149) - - device = jsonlib.loads(base64.b64decode(_real_session_api()._login_headers()["X-TR-Device-Info"])) - - assert device["browserVersion"] == "149.0.0.0" + assert _device_info_of(reimported_with(**{ENV_USER_AGENT: CHROME_149}))["browserVersion"] == "149.0.0.0" -def test_a_non_chrome_user_agent_leaves_the_browser_version_empty(monkeypatch): +def test_a_non_chrome_user_agent_leaves_the_browser_version_empty(reimported_with): """The frontend omits what the browser does not provide, and TR accepts that.""" - monkeypatch.setenv(ENV_USER_AGENT, "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0") - - device = jsonlib.loads(base64.b64decode(_real_session_api()._login_headers()["X-TR-Device-Info"])) - - assert device["browserVersion"] == "" + assert _device_info_of(reimported_with(**{ENV_USER_AGENT: FIREFOX_128}))["browserVersion"] == "" # --- endpoints that must NOT move ----------------------------------------------------