cronometer-api-mcp 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. cronometer_api_mcp-0.2.3/.env.example +6 -0
  2. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/PKG-INFO +21 -3
  3. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/README.md +20 -2
  4. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/pyproject.toml +1 -1
  5. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/server.json +14 -2
  6. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/src/cronometer_api_mcp/client.py +75 -24
  7. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/tests/test_timezone.py +177 -2
  8. cronometer_api_mcp-0.2.3/tests/test_totp.py +107 -0
  9. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/uv.lock +21 -21
  10. cronometer_api_mcp-0.2.2/.env.example +0 -2
  11. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.dockerignore +0 -0
  12. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.github/dependabot.yml +0 -0
  13. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.github/workflows/ci.yml +0 -0
  14. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.github/workflows/docker.yml +0 -0
  15. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.github/workflows/publish.yml +0 -0
  16. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.gitignore +0 -0
  17. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/.python-version +0 -0
  18. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/Dockerfile +0 -0
  19. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/LICENSE +0 -0
  20. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/src/cronometer_api_mcp/__init__.py +0 -0
  21. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/src/cronometer_api_mcp/server.py +0 -0
  22. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/tests/test_client.py +0 -0
  23. {cronometer_api_mcp-0.2.2 → cronometer_api_mcp-0.2.3}/tests/test_server_import.py +0 -0
@@ -0,0 +1,6 @@
1
+ CRONOMETER_USERNAME=
2
+ CRONOMETER_PASSWORD=
3
+ # Only for accounts with two-factor authentication: the base32 key shown at 2FA setup
4
+ CRONOMETER_TOTP_SECRET=
5
+ # Optional IANA timezone override, for example America/Los_Angeles
6
+ CRONOMETER_ACCOUNT_TZ=
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cronometer-api-mcp
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: MCP server for Cronometer nutrition data using the mobile REST API
5
5
  Author: Randy
6
6
  License-Expression: MIT
@@ -59,6 +59,20 @@ export CRONOMETER_USERNAME="your@email.com"
59
59
  export CRONOMETER_PASSWORD="your-password"
60
60
  ```
61
61
 
62
+ #### Optional: two-factor authentication
63
+
64
+ If the account has two-factor authentication enabled, `/api/v2/login`
65
+ answers `TOTP_CODE_REQUIRED` unless the request carries the current 6-digit
66
+ code. Give the server the base32 key that Cronometer showed when 2FA was set
67
+ up (the same key you scanned into your authenticator app) and it derives the
68
+ code itself at every login (RFC 6238, SHA-1, 30 s period):
69
+
70
+ ```bash
71
+ export CRONOMETER_TOTP_SECRET="ABCD EFGH IJKL MNOP QRST UVWX YZ23 4567"
72
+ ```
73
+
74
+ Spaces and lowercase are fine. Leave it unset for accounts without 2FA.
75
+
62
76
  #### Optional: override the account timezone
63
77
 
64
78
  Diary entries are stamped in your Cronometer account's timezone, which the
@@ -88,7 +102,9 @@ cached session, so it also overrides a stale cached timezone.
88
102
  "command": ["uvx", "cronometer-api-mcp"],
89
103
  "environment": {
90
104
  "CRONOMETER_USERNAME": "{env:CRONOMETER_USERNAME}",
91
- "CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}"
105
+ "CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}",
106
+ "CRONOMETER_TOTP_SECRET": "{env:CRONOMETER_TOTP_SECRET}",
107
+ "CRONOMETER_ACCOUNT_TZ": "{env:CRONOMETER_ACCOUNT_TZ}"
92
108
  },
93
109
  "enabled": true
94
110
  }
@@ -106,7 +122,9 @@ cached session, so it also overrides a stale cached timezone.
106
122
  "args": ["cronometer-api-mcp"],
107
123
  "env": {
108
124
  "CRONOMETER_USERNAME": "your@email.com",
109
- "CRONOMETER_PASSWORD": "your-password"
125
+ "CRONOMETER_PASSWORD": "your-password",
126
+ "CRONOMETER_TOTP_SECRET": "your-base32-key",
127
+ "CRONOMETER_ACCOUNT_TZ": "America/Los_Angeles"
110
128
  }
111
129
  }
112
130
  }
@@ -39,6 +39,20 @@ export CRONOMETER_USERNAME="your@email.com"
39
39
  export CRONOMETER_PASSWORD="your-password"
40
40
  ```
41
41
 
42
+ #### Optional: two-factor authentication
43
+
44
+ If the account has two-factor authentication enabled, `/api/v2/login`
45
+ answers `TOTP_CODE_REQUIRED` unless the request carries the current 6-digit
46
+ code. Give the server the base32 key that Cronometer showed when 2FA was set
47
+ up (the same key you scanned into your authenticator app) and it derives the
48
+ code itself at every login (RFC 6238, SHA-1, 30 s period):
49
+
50
+ ```bash
51
+ export CRONOMETER_TOTP_SECRET="ABCD EFGH IJKL MNOP QRST UVWX YZ23 4567"
52
+ ```
53
+
54
+ Spaces and lowercase are fine. Leave it unset for accounts without 2FA.
55
+
42
56
  #### Optional: override the account timezone
43
57
 
44
58
  Diary entries are stamped in your Cronometer account's timezone, which the
@@ -68,7 +82,9 @@ cached session, so it also overrides a stale cached timezone.
68
82
  "command": ["uvx", "cronometer-api-mcp"],
69
83
  "environment": {
70
84
  "CRONOMETER_USERNAME": "{env:CRONOMETER_USERNAME}",
71
- "CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}"
85
+ "CRONOMETER_PASSWORD": "{env:CRONOMETER_PASSWORD}",
86
+ "CRONOMETER_TOTP_SECRET": "{env:CRONOMETER_TOTP_SECRET}",
87
+ "CRONOMETER_ACCOUNT_TZ": "{env:CRONOMETER_ACCOUNT_TZ}"
72
88
  },
73
89
  "enabled": true
74
90
  }
@@ -86,7 +102,9 @@ cached session, so it also overrides a stale cached timezone.
86
102
  "args": ["cronometer-api-mcp"],
87
103
  "env": {
88
104
  "CRONOMETER_USERNAME": "your@email.com",
89
- "CRONOMETER_PASSWORD": "your-password"
105
+ "CRONOMETER_PASSWORD": "your-password",
106
+ "CRONOMETER_TOTP_SECRET": "your-base32-key",
107
+ "CRONOMETER_ACCOUNT_TZ": "America/Los_Angeles"
90
108
  }
91
109
  }
92
110
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cronometer-api-mcp"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "MCP server for Cronometer nutrition data using the mobile REST API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/rwestergren/cronometer-api-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.2",
9
+ "version": "0.2.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "pypi",
13
13
  "identifier": "cronometer-api-mcp",
14
- "version": "0.2.2",
14
+ "version": "0.2.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -27,6 +27,18 @@
27
27
  "description": "Your Cronometer account password",
28
28
  "isRequired": true,
29
29
  "isSecret": true
30
+ },
31
+ {
32
+ "name": "CRONOMETER_TOTP_SECRET",
33
+ "description": "Base32 key for accounts with two-factor authentication",
34
+ "isRequired": false,
35
+ "isSecret": true
36
+ },
37
+ {
38
+ "name": "CRONOMETER_ACCOUNT_TZ",
39
+ "description": "Optional IANA timezone override, for example America/Los_Angeles",
40
+ "isRequired": false,
41
+ "isSecret": false
30
42
  }
31
43
  ]
32
44
  }
@@ -9,9 +9,13 @@ Frida-based traffic capture that established the auth flow and initial
9
9
  endpoints.
10
10
  """
11
11
 
12
+ import base64
13
+ import hashlib
14
+ import hmac
12
15
  import json
13
16
  import logging
14
17
  import os
18
+ import struct
15
19
  import threading
16
20
  import time
17
21
  from datetime import date, datetime
@@ -24,9 +28,9 @@ logger = logging.getLogger(__name__)
24
28
 
25
29
  BASE_URL = "https://mobile.cronometer.com"
26
30
 
27
- # Fallback timezone used only if the account's timezone can't be resolved from
28
- # the login response. Matches the value historically assumed by this client.
29
- _DEFAULT_TIMEZONE = "America/New_York"
31
+ # Only when the zone can't be resolved at all. UTC, not a plausible zone to
32
+ # make fallback more obvious
33
+ _FALLBACK_TIMEZONE = "UTC"
30
34
 
31
35
  # Optional deploy-time override for the account timezone. When set to a valid
32
36
  # IANA zone name it is authoritative over both the login response and any
@@ -120,6 +124,23 @@ class CronometerError(Exception):
120
124
  """Raised when a Cronometer API call fails."""
121
125
 
122
126
 
127
+ def _totp_code(secret: str, for_time: float | None = None) -> str:
128
+ """Current RFC 6238 TOTP code (SHA-1, 30 s period, 6 digits) for `secret`.
129
+
130
+ `secret` is the base32 key shown by Cronometer when enabling two-factor
131
+ authentication; authenticator apps display it grouped and sometimes
132
+ lowercased, so whitespace and case are normalized before decoding.
133
+ """
134
+ normalized = "".join(secret.split()).upper()
135
+ normalized += "=" * (-len(normalized) % 8)
136
+ key = base64.b32decode(normalized)
137
+ counter = int((time.time() if for_time is None else for_time) // 30)
138
+ digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
139
+ offset = digest[-1] & 0x0F
140
+ code = int.from_bytes(digest[offset : offset + 4], "big") & 0x7FFFFFFF
141
+ return f"{code % 1_000_000:06d}"
142
+
143
+
123
144
  class CronometerClient:
124
145
  """Stateful client for the Cronometer mobile API.
125
146
 
@@ -185,10 +206,13 @@ class CronometerClient:
185
206
  return
186
207
  token = data.get("token")
187
208
  user_id = data.get("user_id")
188
- timezone = data.get("timezone")
189
- # Reject caches that lack a stored timezone (pre-timezone schema) so we
190
- # re-login once and pick up the account zone rather than guessing.
191
- if not isinstance(timezone, str):
209
+ # Missing key is the pre-timezone schema: re-login to learn the zone.
210
+ # Explicit null means the account has none, so honour the cache instead
211
+ # of re-logging in every start against a rate-limited endpoint.
212
+ if "timezone" not in data:
213
+ return
214
+ timezone = data["timezone"]
215
+ if timezone is not None and not isinstance(timezone, str):
192
216
  return
193
217
  if isinstance(token, str) and isinstance(user_id, int):
194
218
  self._user_id = user_id
@@ -251,6 +275,11 @@ class CronometerClient:
251
275
  )
252
276
  return username, password
253
277
 
278
+ @staticmethod
279
+ def _totp_user_code() -> str | None:
280
+ secret = os.getenv("CRONOMETER_TOTP_SECRET")
281
+ return _totp_code(secret) if secret else None
282
+
254
283
  def login(self) -> None:
255
284
  """Authenticate with Cronometer and cache the session token.
256
285
 
@@ -271,7 +300,9 @@ class CronometerClient:
271
300
  # leaves the account setting untouched and the response echoes the
272
301
  # account's real zone.
273
302
  "timezone": None,
274
- "userCode": None,
303
+ # 6-digit TOTP code for accounts with two-factor authentication,
304
+ # derived from CRONOMETER_TOTP_SECRET; null when 2FA is not in use.
305
+ "userCode": self._totp_user_code(),
275
306
  "build": "4.48.2 b2807-a",
276
307
  "device": "Android 14 (SDK 34), Google Pixel 6 Pro",
277
308
  "firebaseToken": "",
@@ -295,6 +326,12 @@ class CronometerClient:
295
326
  data = resp.json()
296
327
 
297
328
  if data.get("result") != "SUCCESS" and "sessionKey" not in data:
329
+ if data.get("error") == "TOTP_CODE_REQUIRED":
330
+ raise CronometerError(
331
+ "Login failed: the account has two-factor authentication "
332
+ "enabled; set CRONOMETER_TOTP_SECRET to the base32 key "
333
+ "shown when 2FA was set up"
334
+ )
298
335
  raise CronometerError(f"Login failed: {data}")
299
336
 
300
337
  self._user_id = data["id"]
@@ -463,38 +500,52 @@ class CronometerClient:
463
500
  return None
464
501
  return name
465
502
 
466
- def _resolve_timezone(self, response_tz: str | None) -> str:
467
- """Resolve the account timezone by priority.
503
+ def _resolve_timezone(self, response_tz: str | None) -> str | None:
504
+ """Env override, else the login response. None when neither is usable.
468
505
 
469
- 1. CRONOMETER_ACCOUNT_TZ env override (authoritative escape hatch).
470
- 2. The value from the login response (trustworthy now that login()
471
- no longer overwrites the account's server-side zone; see #29).
472
- 3. The historical default.
506
+ Validates the response: storing an unknown name defers the failure to
507
+ ``_tzinfo``, where it is no longer attributable to Cronometer.
473
508
  """
474
509
  env = self._env_timezone()
475
510
  if env:
476
511
  return env
477
512
  if isinstance(response_tz, str) and response_tz:
478
- return response_tz
479
- return _DEFAULT_TIMEZONE
513
+ try:
514
+ ZoneInfo(response_tz)
515
+ return response_tz
516
+ except ZoneInfoNotFoundError, ValueError:
517
+ logger.warning(
518
+ "Cronometer reported unknown timezone %r; ignoring", response_tz
519
+ )
520
+ return None
480
521
 
481
522
  def _tzinfo(self) -> ZoneInfo:
482
- """Return the account's timezone, falling back to the default.
523
+ """The account's zone, logging in if that's the only way to learn it.
483
524
 
484
- Resolved from the login response (or restored session cache). If the
485
- stored name is unset or unknown (e.g. a zone missing from the system
486
- tzdata), log once and fall back so stamping never hard-fails.
525
+ Every consumer funnels through here, so this is where the zone has to
526
+ be guaranteed resolved: callers used to read the clock pre-auth and get
527
+ the fallback, correcting only once something else triggered login.
528
+
529
+ Env override first, so an injected zone needs no network.
487
530
  """
488
- name = self._timezone or _DEFAULT_TIMEZONE
531
+ name = self._env_timezone()
532
+ if not name:
533
+ self._ensure_auth()
534
+ name = self._timezone
535
+ if not name:
536
+ logger.warning(
537
+ "No account timezone available; stamping in %s", _FALLBACK_TIMEZONE
538
+ )
539
+ return ZoneInfo(_FALLBACK_TIMEZONE)
489
540
  try:
490
541
  return ZoneInfo(name)
491
542
  except ZoneInfoNotFoundError, ValueError:
492
543
  logger.warning(
493
- "Unknown account timezone %r; falling back to %s",
544
+ "Unknown account timezone %r; stamping in %s",
494
545
  name,
495
- _DEFAULT_TIMEZONE,
546
+ _FALLBACK_TIMEZONE,
496
547
  )
497
- return ZoneInfo(_DEFAULT_TIMEZONE)
548
+ return ZoneInfo(_FALLBACK_TIMEZONE)
498
549
 
499
550
  def now(self) -> datetime:
500
551
  """Current wall-clock time in the account's timezone (aware)."""
@@ -14,7 +14,7 @@ from zoneinfo import ZoneInfo
14
14
 
15
15
  import pytest
16
16
 
17
- from cronometer_api_mcp.client import CronometerClient
17
+ from cronometer_api_mcp.client import _FALLBACK_TIMEZONE, CronometerClient
18
18
 
19
19
 
20
20
  class FakeResp:
@@ -306,4 +306,179 @@ def test_pre_timezone_cache_is_rejected(tmp_path, monkeypatch):
306
306
  def test_unknown_timezone_falls_back(tmp_path):
307
307
  """An unresolvable zone name falls back rather than raising."""
308
308
  client = _client(tmp_path, "Not/AZone")
309
- assert client._tzinfo() == ZoneInfo("America/New_York")
309
+ assert client._tzinfo() == ZoneInfo(_FALLBACK_TIMEZONE)
310
+
311
+
312
+ # ---- cold start: the clock must not be read before the zone is known -------
313
+ #
314
+ # Regression: callers read the clock pre-auth, so the first entry in a fresh
315
+ # container stamped in the fallback zone and later ones were correct -- which
316
+ # read as intermittent drift.
317
+
318
+
319
+ def _cold_client(
320
+ tmp_path: Path, response_tz: str | None
321
+ ) -> tuple[CronometerClient, dict]:
322
+ """An unauthenticated client plus a captured-payload dict.
323
+
324
+ Stubs login and add_serving on one fake. Nothing pre-seeds ``_timezone`` --
325
+ that is the condition under test.
326
+ """
327
+ client = CronometerClient(session_path=tmp_path / "session.json")
328
+ captured: dict = {"logins": 0}
329
+
330
+ def fake_post(endpoint, json=None):
331
+ if endpoint == "/api/v2/login":
332
+ captured["logins"] += 1
333
+ body = {"result": "SUCCESS", "id": 123, "sessionKey": "TOKEN"}
334
+ if response_tz is not None:
335
+ body["timezone"] = response_tz
336
+ return FakeResp(body)
337
+ captured["endpoint"] = endpoint
338
+ captured["payload"] = json
339
+ return FakeResp({"result": "SUCCESS", "id": 999})
340
+
341
+ client._http.post = fake_post # type: ignore[method-assign]
342
+ return client, captured
343
+
344
+
345
+ def test_first_add_serving_in_a_cold_client_stamps_in_account_zone(
346
+ tmp_path, frozen_utc, monkeypatch
347
+ ):
348
+ """The prod regression: 18:01 UTC is 11:01 PDT, not 14:01 Eastern."""
349
+ monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
350
+ monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
351
+ monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
352
+ client, captured = _cold_client(tmp_path, "America/Los_Angeles")
353
+
354
+ assert client._timezone is None # nothing resolved yet
355
+ client.add_serving(food_id=1, measure_id=0, grams=100.0)
356
+
357
+ serving = captured["payload"]["serving"]
358
+ assert serving["time"] == "11:1:30"
359
+ assert serving["day"] == "2026-7-27"
360
+ # Lunch; Eastern would have made it Dinner.
361
+ assert serving["order"] == (2 << 16) | 1
362
+
363
+
364
+ def test_cold_client_day_bucketing_uses_account_zone(tmp_path, monkeypatch):
365
+ """A late-evening PDT entry must not slip a day, as a fallback zone would."""
366
+ monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
367
+ monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
368
+ monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
369
+
370
+ class LateEvening(_dt.datetime):
371
+ # 05:30 UTC on the 28th == 22:30 PDT on the 27th.
372
+ _fixed_utc = _dt.datetime(2026, 7, 28, 5, 30, 0, tzinfo=_dt.UTC)
373
+
374
+ @classmethod
375
+ def now(cls, tz=None):
376
+ if tz is None:
377
+ return cls._fixed_utc.replace(tzinfo=None)
378
+ return cls._fixed_utc.astimezone(tz)
379
+
380
+ monkeypatch.setattr("cronometer_api_mcp.client.datetime", LateEvening)
381
+ client, captured = _cold_client(tmp_path, "America/Los_Angeles")
382
+
383
+ client.add_serving(food_id=1, measure_id=0, grams=100.0)
384
+
385
+ assert captured["payload"]["serving"]["day"] == "2026-7-27"
386
+
387
+
388
+ def test_env_override_needs_no_login_to_stamp(tmp_path, frozen_utc, monkeypatch):
389
+ """An injected zone leaves nothing to learn, so no login -- which is what
390
+ makes a cold container correct on its first entry."""
391
+ monkeypatch.setenv("CRONOMETER_ACCOUNT_TZ", "America/Los_Angeles")
392
+ client, captured = _cold_client(tmp_path, "America/New_York")
393
+
394
+ assert client._tzinfo() == ZoneInfo("America/Los_Angeles")
395
+ assert captured["logins"] == 0
396
+
397
+
398
+ def test_tzinfo_authenticates_when_zone_is_unknown(tmp_path, frozen_utc, monkeypatch):
399
+ """Without an override the login response is the only source."""
400
+ monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
401
+ monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
402
+ monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
403
+ client, captured = _cold_client(tmp_path, "America/Los_Angeles")
404
+
405
+ assert client._tzinfo() == ZoneInfo("America/Los_Angeles")
406
+ assert captured["logins"] == 1
407
+
408
+
409
+ def test_cold_today_uses_account_zone(tmp_path, frozen_utc, monkeypatch):
410
+ """today() defaults a date for many tools, so it must resolve too."""
411
+ monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
412
+ monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
413
+ monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
414
+ client, _ = _cold_client(tmp_path, "America/Los_Angeles")
415
+
416
+ assert client.today() == _dt.date(2026, 7, 27)
417
+
418
+
419
+ # ---- fallback must not look plausible -------------------------------------
420
+
421
+
422
+ def test_missing_response_zone_falls_back_to_utc(tmp_path, frozen_utc, monkeypatch):
423
+ """No reported zone -> UTC. Eastern was a silent 3h lie for a PDT user."""
424
+ monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
425
+ monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
426
+ monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
427
+ client, _ = _cold_client(tmp_path, None)
428
+
429
+ assert client._tzinfo() == ZoneInfo("UTC")
430
+
431
+
432
+ def test_unresolvable_response_zone_is_rejected_at_the_boundary(
433
+ tmp_path, frozen_utc, monkeypatch
434
+ ):
435
+ """Rejected where it's still attributable to Cronometer."""
436
+ monkeypatch.setenv("CRONOMETER_USERNAME", "u@example.com")
437
+ monkeypatch.setenv("CRONOMETER_PASSWORD", "pw")
438
+ monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
439
+ client, _ = _cold_client(tmp_path, "Mars/Olympus_Mons")
440
+
441
+ client.login()
442
+ assert client._timezone is None
443
+ assert client._tzinfo() == ZoneInfo("UTC")
444
+
445
+
446
+ def test_unknown_stored_zone_falls_back_to_utc(tmp_path):
447
+ """A zone this tzdata build can't construct is our problem, not theirs."""
448
+ client = _client(tmp_path, "Not/AZone")
449
+ assert client._tzinfo() == ZoneInfo("UTC")
450
+
451
+
452
+ # ---- session cache must not thrash logins for zoneless accounts ------------
453
+
454
+
455
+ def test_cache_with_null_timezone_is_reused(tmp_path, monkeypatch):
456
+ """None is now a legitimate resolved value, persisted as null. Treating it
457
+ like the legacy schema would re-login on every container start."""
458
+ import json
459
+
460
+ monkeypatch.setenv("CRONOMETER_USERNAME", "")
461
+ monkeypatch.delenv("CRONOMETER_ACCOUNT_TZ", raising=False)
462
+ (tmp_path / "session.json").write_text(
463
+ json.dumps({"username": "", "user_id": 123, "token": "TOKEN", "timezone": None})
464
+ )
465
+
466
+ client = CronometerClient(session_path=tmp_path / "session.json")
467
+
468
+ assert client._token == "TOKEN"
469
+ assert client._timezone is None
470
+ assert client._tzinfo() == ZoneInfo(_FALLBACK_TIMEZONE)
471
+
472
+
473
+ def test_cache_predating_timezone_support_is_still_rejected(tmp_path, monkeypatch):
474
+ """No timezone key is the legacy schema: re-login to learn the zone."""
475
+ import json
476
+
477
+ monkeypatch.setenv("CRONOMETER_USERNAME", "")
478
+ (tmp_path / "session.json").write_text(
479
+ json.dumps({"username": "", "user_id": 123, "token": "TOKEN"})
480
+ )
481
+
482
+ client = CronometerClient(session_path=tmp_path / "session.json")
483
+
484
+ assert client._token is None
@@ -0,0 +1,107 @@
1
+ """Tests for optional TOTP (two-factor) support at login.
2
+
3
+ Accounts with two-factor authentication enabled get `{"result": "FAIL",
4
+ "error": "TOTP_CODE_REQUIRED"}` from /api/v2/login unless the request carries
5
+ the current 6-digit code in `userCode`. When CRONOMETER_TOTP_SECRET is set the
6
+ client derives that code itself (RFC 6238, SHA-1, 30 s period, 6 digits).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from cronometer_api_mcp.client import CronometerClient, CronometerError, _totp_code
16
+
17
+ # RFC 6238 Appendix B test vectors (SHA-1). The secret is the ASCII string
18
+ # "12345678901234567890"; the 6-digit codes are the last six digits of the
19
+ # 8-digit values listed in the RFC.
20
+ RFC6238_SECRET_B32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
21
+ RFC6238_VECTORS = [
22
+ (59, "287082"),
23
+ (1111111109, "081804"),
24
+ (1234567890, "005924"),
25
+ (2000000000, "279037"),
26
+ ]
27
+
28
+ SUCCESS_BODY = {
29
+ "result": "SUCCESS",
30
+ "id": 123,
31
+ "sessionKey": "TOKEN",
32
+ "timezone": "UTC",
33
+ }
34
+ TOTP_REQUIRED_BODY = {"result": "FAIL", "error": "TOTP_CODE_REQUIRED"}
35
+
36
+
37
+ class FakeResp:
38
+ def __init__(self, body) -> None:
39
+ self._body = body
40
+ self.status_code = 200
41
+
42
+ def raise_for_status(self) -> None: # pragma: no cover - trivial
43
+ pass
44
+
45
+ def json(self):
46
+ return self._body
47
+
48
+
49
+ def make_client(tmp_path: Path, body: dict):
50
+ """Client whose login POST is captured and answered with `body`."""
51
+ client = CronometerClient(session_path=tmp_path / "session.json")
52
+ captured: dict = {}
53
+
54
+ def fake_post(endpoint, json=None):
55
+ captured["endpoint"] = endpoint
56
+ captured["payload"] = json
57
+ return FakeResp(body)
58
+
59
+ client._http.post = fake_post # type: ignore[method-assign]
60
+ return client, captured
61
+
62
+
63
+ @pytest.fixture
64
+ def credentials(monkeypatch):
65
+ monkeypatch.setenv("CRONOMETER_USERNAME", "user@example.com")
66
+ monkeypatch.setenv("CRONOMETER_PASSWORD", "secret")
67
+ monkeypatch.delenv("CRONOMETER_TOTP_SECRET", raising=False)
68
+
69
+
70
+ @pytest.mark.parametrize(("at", "expected"), RFC6238_VECTORS)
71
+ def test_totp_code_matches_rfc6238_sha1_vectors(at, expected):
72
+ assert _totp_code(RFC6238_SECRET_B32, at) == expected
73
+
74
+
75
+ def test_totp_code_accepts_lowercase_and_spaced_secret():
76
+ """Authenticator apps display the key grouped and sometimes lowercased."""
77
+ spaced = "gezd gnbv gy3t qojq gezd gnbv gy3t qojq"
78
+ assert _totp_code(spaced, 59) == "287082"
79
+
80
+
81
+ def test_login_sends_null_user_code_without_secret(credentials, tmp_path):
82
+ client, captured = make_client(tmp_path, SUCCESS_BODY)
83
+
84
+ client.login()
85
+
86
+ assert captured["endpoint"] == "/api/v2/login"
87
+ assert captured["payload"]["userCode"] is None
88
+
89
+
90
+ def test_login_sends_current_totp_code_when_secret_set(
91
+ credentials, tmp_path, monkeypatch
92
+ ):
93
+ monkeypatch.setenv("CRONOMETER_TOTP_SECRET", RFC6238_SECRET_B32)
94
+ client, captured = make_client(tmp_path, SUCCESS_BODY)
95
+
96
+ client.login()
97
+
98
+ code = captured["payload"]["userCode"]
99
+ assert isinstance(code, str) and len(code) == 6 and code.isdigit()
100
+ assert code == _totp_code(RFC6238_SECRET_B32)
101
+
102
+
103
+ def test_login_totp_required_without_secret_points_at_env_var(credentials, tmp_path):
104
+ client, _ = make_client(tmp_path, TOTP_REQUIRED_BODY)
105
+
106
+ with pytest.raises(CronometerError, match="CRONOMETER_TOTP_SECRET"):
107
+ client.login()
@@ -554,27 +554,27 @@ wheels = [
554
554
 
555
555
  [[package]]
556
556
  name = "ruff"
557
- version = "0.16.4"
558
- source = { registry = "https://pypi.org/simple" }
559
- sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
560
- wheels = [
561
- { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
562
- { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
563
- { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
564
- { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
565
- { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
566
- { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
567
- { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
568
- { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
569
- { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
570
- { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
571
- { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
572
- { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
573
- { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
574
- { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
575
- { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" },
576
- { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" },
577
- { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
557
+ version = "0.16.6"
558
+ source = { registry = "https://pypi.org/simple" }
559
+ sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" }
560
+ wheels = [
561
+ { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" },
562
+ { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" },
563
+ { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" },
564
+ { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" },
565
+ { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" },
566
+ { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" },
567
+ { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" },
568
+ { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" },
569
+ { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" },
570
+ { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" },
571
+ { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" },
572
+ { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" },
573
+ { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" },
574
+ { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" },
575
+ { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" },
576
+ { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
577
+ { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
578
578
  ]
579
579
 
580
580
  [[package]]
@@ -1,2 +0,0 @@
1
- CRONOMETER_USERNAME=
2
- CRONOMETER_PASSWORD=