From 56f1c58c1b7864c0c36e7bc3310abc36781d11b2 Mon Sep 17 00:00:00 2001 From: Randy Westergren Date: Sun, 2 Aug 2026 09:31:16 -0400 Subject: [PATCH] Send null timezone at login so account zone isn't overwritten (#29) The login request's timezone field is a write, not a hint: sending a non-null zone overwrites the account's server-side timezone on every login (verified against the live API). Older builds hardcoded "America/New_York", silently resetting every user's account zone to Eastern and poisoning the account-timezone stamping added in #27. Send null instead, which leaves the account setting intact and lets the response report the real zone. Add a CRONOMETER_ACCOUNT_TZ override that takes precedence over both the response and a cached session, as an escape hatch for accounts whose zone was already clobbered. --- README.md | 14 ++++ src/cronometer_api_mcp/client.py | 66 +++++++++++++++-- tests/test_timezone.py | 121 +++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 319eeaf..71f7ac4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ export CRONOMETER_USERNAME="your@email.com" export CRONOMETER_PASSWORD="your-password" ``` +#### Optional: override the account timezone + +Diary entries are stamped in your Cronometer account's timezone, which the +server reports at login. If that zone is wrong (for example, an older build +had reset it) you can force a specific IANA zone without changing your account +settings: + +```bash +export CRONOMETER_ACCOUNT_TZ="America/Los_Angeles" +``` + +When set, this takes precedence over both the value reported at login and any +cached session, so it also overrides a stale cached timezone. + ### 3. Configure your MCP client `uvx` downloads and runs the server on demand -- no separate install step. diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 4b54fba..07c73c0 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -26,6 +26,13 @@ # the login response. Matches the value historically assumed by this client. _DEFAULT_TIMEZONE = "America/New_York" +# Optional deploy-time override for the account timezone. When set to a valid +# IANA zone name it is authoritative over both the login response and any +# cached value. This is the escape hatch for accounts whose server-side zone +# was clobbered by older builds (see issue #29) or when the resolved zone is +# otherwise wrong. +_ACCOUNT_TZ_ENV = "CRONOMETER_ACCOUNT_TZ" + # Cache the auth token across processes to avoid /api/v2/login rate limits. # Cronometer throttles repeated logins per account; reusing a sessionKey lets # short-lived CLI invocations behave like a long-running app. @@ -145,11 +152,14 @@ def _load_cached_session(self) -> None: if isinstance(token, str) and isinstance(user_id, int): self._user_id = user_id self._token = token - self._timezone = timezone + # A CRONOMETER_ACCOUNT_TZ override wins over the cached value so a + # session.json poisoned by an older build (issue #29) can't defeat + # an explicit deploy-time setting without invalidating the cache. + self._timezone = self._resolve_timezone(timezone) logger.debug( "Restored Cronometer session for user_id=%d (tz=%s) from %s", user_id, - timezone, + self._timezone, self._session_path, ) @@ -205,7 +215,15 @@ def login(self) -> None: payload = { "email": username, "password": password, - "timezone": "America/New_York", + # Must stay null: the login endpoint treats a non-null timezone as + # a *write* that overwrites the account's server-side zone (verified + # against the live API — sending "Asia/Tokyo" changed the account + # setting and it persisted across subsequent logins). Older builds + # hardcoded "America/New_York" here, silently resetting every user's + # account zone to Eastern on each login (issue #29). Sending null + # leaves the account setting untouched and the response echoes the + # account's real zone. + "timezone": None, "userCode": None, "build": "4.48.2 b2807-a", "device": "Android 14 (SDK 34), Google Pixel 6 Pro", @@ -235,9 +253,9 @@ def login(self) -> None: self._token = data["sessionKey"] # The login response embeds the account profile, including the user's # configured IANA timezone. Prefer it over the host clock so diary - # timestamps are correct regardless of where the server runs. - tz = data.get("timezone") - self._timezone = tz if isinstance(tz, str) and tz else _DEFAULT_TIMEZONE + # timestamps are correct regardless of where the server runs. A + # CRONOMETER_ACCOUNT_TZ override, if set, wins over the response. + self._timezone = self._resolve_timezone(data.get("timezone")) self._save_cached_session() logger.info( "Cronometer login successful (userId=%d, tz=%s, token=%s...)", @@ -348,6 +366,42 @@ def _request_v3( # Date helpers # ------------------------------------------------------------------ + @staticmethod + def _env_timezone() -> str | None: + """Return a valid IANA zone from CRONOMETER_ACCOUNT_TZ, or None. + + An invalid name is logged and ignored so a typo can't hard-fail + startup; resolution then falls through to the response/cache value. + """ + name = os.getenv(_ACCOUNT_TZ_ENV) + if not name: + return None + try: + ZoneInfo(name) + except ZoneInfoNotFoundError, ValueError: + logger.warning( + "Ignoring invalid %s=%r (not a known IANA timezone)", + _ACCOUNT_TZ_ENV, + name, + ) + return None + return name + + def _resolve_timezone(self, response_tz: str | None) -> str: + """Resolve the account timezone by priority. + + 1. CRONOMETER_ACCOUNT_TZ env override (authoritative escape hatch). + 2. The value from the login response (trustworthy now that login() + no longer overwrites the account's server-side zone; see #29). + 3. The historical default. + """ + env = self._env_timezone() + if env: + return env + if isinstance(response_tz, str) and response_tz: + return response_tz + return _DEFAULT_TIMEZONE + def _tzinfo(self) -> ZoneInfo: """Return the account's timezone, falling back to the default. diff --git a/tests/test_timezone.py b/tests/test_timezone.py index 84b5225..3a51dfe 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -148,6 +148,127 @@ def fake_post(endpoint, json=None): assert saved["timezone"] == "America/Los_Angeles" +def test_login_sends_null_request_timezone(tmp_path, monkeypatch): + """login() must send timezone:null in the request. A non-null value is a + *write* that overwrites the account's server-side zone (issue #29), so the + field must never be hardcoded again -- this is a regression guard.""" + monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com") + monkeypatch.setenv("CRONOMETER_PASSWORD", "pw") + monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False) + client = CronometerClient(session_path=tmp_path / "session.json") + + captured: dict = {} + + def fake_post(endpoint, json=None): + captured["payload"] = json + return FakeResp( + { + "result": "SUCCESS", + "id": 1, + "sessionKey": "K", + "timezone": "America/Los_Angeles", + } + ) + + client._http.post = fake_post # type: ignore[method-assign] + client.login() + + assert "timezone" in captured["payload"] + assert captured["payload"]["timezone"] is None + # The response zone is trusted now that we no longer overwrite it. + assert client._timezone == "America/Los_Angeles" + + +def test_env_override_wins_over_login_response(tmp_path, monkeypatch): + """CRONOMETER_ACCOUNT_TZ is authoritative over the login response zone.""" + monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com") + monkeypatch.setenv("CRONOMETER_PASSWORD", "pw") + monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "America/Los_Angeles") + client = CronometerClient(session_path=tmp_path / "session.json") + + def fake_post(endpoint, json=None): + return FakeResp( + { + "result": "SUCCESS", + "id": 1, + "sessionKey": "K", + "timezone": "America/New_York", + } + ) + + client._http.post = fake_post # type: ignore[method-assign] + client.login() + + assert client._timezone == "America/Los_Angeles" + import json + + saved = json.loads((tmp_path / "session.json").read_text()) + assert saved["timezone"] == "America/Los_Angeles" + + +def test_env_override_wins_over_poisoned_cache(tmp_path, monkeypatch): + """A session.json poisoned by an older build must not defeat an explicit + CRONOMETER_ACCOUNT_TZ override on warm start (issue #29).""" + monkeypatch.setenv("CRONOMETER_USERNAME", "") + monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "Europe/Berlin") + import json + + (tmp_path / "session.json").write_text( + json.dumps( + { + "username": "", + "user_id": 123, + "token": "TOKEN", + "timezone": "America/New_York", + } + ) + ) + client = CronometerClient(session_path=tmp_path / "session.json") + assert client._token == "TOKEN" + assert client._timezone == "Europe/Berlin" + + +def test_invalid_env_override_is_ignored(tmp_path, monkeypatch): + """A malformed CRONOMETER_ACCOUNT_TZ is ignored, falling through to the + login response rather than hard-failing.""" + monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com") + monkeypatch.setenv("CRONOMETER_PASSWORD", "pw") + monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "Not/AZone") + client = CronometerClient(session_path=tmp_path / "session.json") + + def fake_post(endpoint, json=None): + return FakeResp( + { + "result": "SUCCESS", + "id": 1, + "sessionKey": "K", + "timezone": "America/Chicago", + } + ) + + client._http.post = fake_post # type: ignore[method-assign] + client.login() + + assert client._timezone == "America/Chicago" + + +def test_env_override_stamps_entries(tmp_path, monkeypatch, frozen_utc): + """End-to-end: with the override set, diary stamping uses the override zone + even when the client's stored zone would otherwise be Eastern.""" + monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "America/Los_Angeles") + client = _client(tmp_path, "America/New_York") + # Re-resolve as warm start would, honoring the override. + client._timezone = client._resolve_timezone("America/New_York") + captured = _capture_serving(client) + + client.add_serving(food_id=1, measure_id=0, grams=100.0) + + serving = captured["payload"]["serving"] + # 18:01 UTC -> 11:01 PDT (Los Angeles), not 14:01 EDT. + assert serving["time"] == "11:1:30" + assert serving["day"] == "2026-7-27" + + def test_warm_start_restores_timezone(tmp_path, monkeypatch): """A cache file with a timezone is restored without re-login.""" monkeypatch.setenv("CRONOMETER_USERNAME", "")