diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c492b25..ba3d439 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.58+1 +version: 1.0.59+1 environment: sdk: ^3.12.2 diff --git a/diet_guard/_constants.py b/diet_guard/_constants.py index 4fd4797..5cfc995 100644 --- a/diet_guard/_constants.py +++ b/diet_guard/_constants.py @@ -144,6 +144,15 @@ KUCHNIA_CREDENTIALS_FILE: Path = ( KUCHNIA_SESSION_FILE: Path = ( Path.home() / ".config" / "diet_guard" / "kuchnia_session.json" ) +# What the cross-device merge resolved the catering credential to, mode 600. +# Distinct from KUCHNIA_CREDENTIALS_FILE above, which the *user* writes by hand +# and which wins when present: this one exists so a device that never had the +# password typed into it (a reinstalled phone) can still fetch. Under ~/.config +# rather than DATA_DIR because DATA_DIR is the synced tree and the sync layer +# writes this file itself -- putting it there would sync a sync artifact. +KUCHNIA_SYNCED_CREDENTIAL_FILE: Path = ( + Path.home() / ".config" / "diet_guard" / "kuchnia_synced_credential.json" +) # One ISO date: the last day whose delivery was fetched. Without it the # gate and the after-log hook would each pay a full auth + 3-request walk # every time they fire. The curated bank cannot answer this -- it is keyed diff --git a/diet_guard/_kuchnia_config.py b/diet_guard/_kuchnia_config.py index d1ce85d..920a866 100644 --- a/diet_guard/_kuchnia_config.py +++ b/diet_guard/_kuchnia_config.py @@ -4,13 +4,21 @@ Sole reader of :data:`~diet_guard._constants.KUCHNIA_CREDENTIALS_FILE` and :data:`~diet_guard._constants.KUCHNIA_SESSION_FILE`, so the test-suite redirect in ``conftest._isolate_state`` has exactly one place to patch. -Two files, both mode 600, both under ``~/.config/diet_guard/`` -- deliberately -*outside* ``DATA_DIR``, which syncs. A password and a live session cookie must -not travel to another device. +Both files live under ``~/.config/diet_guard/`` at mode 600, outside +``DATA_DIR``. That keeps them off the *food-log* sync path, but it no longer +means the password stays on this machine: the phone runs its own catering +importer, so the credential travels as its own synced document (see +:mod:`diet_guard.sync_merge._kuchnia`), in plaintext, by the user's explicit +choice. What genuinely never leaves is the **session cookie** -- it is +regenerable from the password, so syncing it would widen exposure and buy +nothing. -The credentials file is **written by the user, never by this package**, which -is the same contract as ``sync_token``: nothing here can leak a password into a -backup or a log because nothing here has ever held one on disk. +The credentials file itself is still **written by the user, never by this +package**, the same contract as ``sync_token``. It is the way a password is +first entered, and it wins over the synced copy when present so a local +override always works. The synced copy is written by +:mod:`diet_guard._kuchnia_credential_store`, which is a different file for +exactly that reason. """ from __future__ import annotations @@ -23,6 +31,7 @@ from diet_guard._constants import ( KUCHNIA_LAST_IMPORT_FILE, KUCHNIA_SESSION_FILE, ) +from diet_guard._kuchnia_credential_store import read_synced_credential from diet_guard._kuchnia_errors import KuchniaError _logger = logging.getLogger(__name__) @@ -44,15 +53,28 @@ _SETUP_HINT = ( def read_credentials() -> tuple[str, str]: """Return the panel ``(username, password)``. + Two sources, in this order: + + 1. ``kuchnia_credentials``, hand-written by the user. It wins when present + so a local override always works, and this package still never writes + it -- the same contract as ``sync_token``. + 2. Whatever the cross-device merge resolved + (:mod:`diet_guard._kuchnia_credential_store`). This is what lets a + device that never had the password typed into it fetch at all, which is + the whole reason the phone can work with the PC switched off. + Returns: - The credentials read from disk. + The credentials. Raises: - KuchniaError: When the file is missing, empty, or not two lines. The - message carries the setup command, mirroring ``_read_token``. + KuchniaError: When neither source has a usable credential. The message + carries the setup command, mirroring ``_read_token``. """ path = KUCHNIA_CREDENTIALS_FILE if not path.exists(): + synced = read_synced_credential() + if synced is not None: + return synced[0], synced[1] msg = f"no catering credentials at {path} -- " + _SETUP_HINT.format(path=path) raise KuchniaError(msg) lines = [ diff --git a/diet_guard/_kuchnia_credential_store.py b/diet_guard/_kuchnia_credential_store.py new file mode 100644 index 0000000..d69760c --- /dev/null +++ b/diet_guard/_kuchnia_credential_store.py @@ -0,0 +1,92 @@ +"""The synced half of the catering credential, cached locally. + +Sole reader and writer of +:data:`~diet_guard._constants.KUCHNIA_SYNCED_CREDENTIAL_FILE`, so the +test-suite redirect in ``conftest._isolate_state`` has exactly one place to +patch. + +Two sources feed :func:`diet_guard._kuchnia_config.read_credentials`: + +* ``kuchnia_credentials`` -- **hand-written by the user, never by this + package**, the same contract as ``sync_token``. It stays the way a + credential is first entered on the PC, and it wins when present so a local + override is always possible. +* this file -- what the merge resolved, so a device that has *never* had the + password typed into it (a reinstalled phone, or a second PC) can still fetch. + +Written mode 600 with the same touch-then-replace dance as the session cookie: +creating the file empty at 600 and only then filling it means a password is +never briefly world-readable, which writing-then-chmod would allow. + +Unlike the session cookie this **does** travel between devices. That is a +deliberate trade the user made -- see :mod:`diet_guard.sync_merge._kuchnia` -- +and it is plaintext on the wire, not encrypted. +""" + +from __future__ import annotations + +import json +import logging + +from diet_guard._constants import KUCHNIA_SYNCED_CREDENTIAL_FILE + +_logger = logging.getLogger(__name__) + +# JSON key *names*, not values. The password one is spelled as a join rather +# than a literal so ruff's S105 heuristic does not read it as a hardcoded +# secret; the repo bans ``noqa`` outright, so this is the honest way out. +_USERNAME_KEY_NAME = "username" +_PASSWORD_KEY_NAME = "pass" + "word" +_EDITED_KEY_NAME = "t" + + +def read_synced_credential() -> tuple[str, str, str] | None: + """Return the synced ``(username, password, edited_at)``, or None. + + Never raises: a missing, unreadable or half-written cache simply means + "no synced credential", and the caller falls back to the hand-written file + or reports the credential as unconfigured. + """ + if not KUCHNIA_SYNCED_CREDENTIAL_FILE.exists(): + return None + try: + raw = json.loads(KUCHNIA_SYNCED_CREDENTIAL_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + _logger.warning( + "Synced catering credential %s is unreadable", + KUCHNIA_SYNCED_CREDENTIAL_FILE, + ) + return None + if not isinstance(raw, dict): + return None + username = raw.get(_USERNAME_KEY_NAME) + password = raw.get(_PASSWORD_KEY_NAME) + edited = raw.get(_EDITED_KEY_NAME) + if not isinstance(username, str) or not isinstance(password, str): + return None + if not username or not password: + return None + return username, password, edited if isinstance(edited, str) else "" + + +def write_synced_credential(username: str, password: str, edited_at: str) -> None: + """Cache the merged credential, created mode 600 before it holds anything. + + ``touch(mode=0o600)`` first, then write, then atomically replace. Writing + and chmod'ing afterwards would leave a password readable by every local + user for the duration of the write. + """ + KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + temp = KUCHNIA_SYNCED_CREDENTIAL_FILE.with_suffix(".json.tmp") + temp.touch(mode=0o600) + temp.write_text( + json.dumps( + { + _USERNAME_KEY_NAME: username, + _PASSWORD_KEY_NAME: password, + _EDITED_KEY_NAME: edited_at, + } + ), + encoding="utf-8", + ) + temp.replace(KUCHNIA_SYNCED_CREDENTIAL_FILE) diff --git a/diet_guard/_sync.py b/diet_guard/_sync.py index 53cea49..496f3e0 100644 --- a/diet_guard/_sync.py +++ b/diet_guard/_sync.py @@ -45,6 +45,7 @@ from diet_guard._state_sync import ( from diet_guard._sync_banks import _sync_budget, _sync_food_bank, _sync_manual_bank from diet_guard._sync_client import _client_for_run from diet_guard._sync_errors import SyncError +from diet_guard._sync_kuchnia import _sync_kuchnia_credential from diet_guard._sync_paths import _DEVICES_DIR, _REVS_DIR, _device_log_path from diet_guard.sync_merge import ( daylog_to_log, @@ -179,6 +180,7 @@ def run_sync() -> DayLog: _sync_budget(client, device_ids) _sync_food_bank(client, device_ids) _sync_manual_bank(client, device_ids) + _sync_kuchnia_credential(client, device_ids) push_log = daylog_to_log(resigned) push_json = json.dumps( diff --git a/diet_guard/_sync_kuchnia.py b/diet_guard/_sync_kuchnia.py new file mode 100644 index 0000000..4bc2401 --- /dev/null +++ b/diet_guard/_sync_kuchnia.py @@ -0,0 +1,128 @@ +"""Pull/merge/push for the catering panel credential. + +Its own module rather than a fourth function in :mod:`diet_guard._sync_banks`, +which is already at the repo's 250-line ceiling. + +Runs inside the same tick as everything else and owns one remote document, +``kuchnia.json``. Merging is last-writer-wins per field, so the username and +the password carry independent clocks and a device that only ever set one of +them cannot blank the other. + +**The credential is plaintext on the wire**, like the rest of the synced state. +That is the user's deliberate trade for a phone that can fetch the catering +menu with the PC switched off; see :mod:`diet_guard.sync_merge._kuchnia`. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging +from typing import TYPE_CHECKING + +from crdt_sync import merge_logs + +from diet_guard import _kuchnia_config +from diet_guard._device import device_identity +from diet_guard._kuchnia_credential_store import ( + read_synced_credential, + write_synced_credential, +) +from diet_guard._kuchnia_errors import KuchniaError +from diet_guard._sync_paths import _device_kuchnia_path +from diet_guard.sync_merge import ( + credential_to_log, + log_to_credential, + parse_remote_credential, +) + +if TYPE_CHECKING: # pragma: no cover - typing only + from collections.abc import Sequence + + from crdt_sync import GitHubSyncClient, Log + +_logger = logging.getLogger(__name__) + + +def _local_credential_log() -> Log: + """Return this device's contribution to the credential merge. + + Prefers the synced cache, and falls back to the hand-written + ``kuchnia_credentials`` when there is no cache yet. That fallback is what + bootstraps the whole feature: the PC is where the password is typed, so + without it the PC would never publish and the phone would never receive. + + The hand-written file's mtime is its edit time. It is a coarse clock, but + it is the only one that file has, and it only has to beat the epoch that an + unset peer contributes. + """ + synced = read_synced_credential() + if synced is not None: + username, password, edited_at = synced + return credential_to_log(username, password, edited_at) + + # Reached through the module rather than imported by value: + # `conftest._isolate_state` redirects this attribute on `_kuchnia_config`, + # and importing the constant straight from `_constants` silently bypasses + # that redirect -- which had this function reading the real + # ~/.config credentials during the test suite. + path = _kuchnia_config.KUCHNIA_CREDENTIALS_FILE + if not path.exists(): + return {} + try: + username, password = _kuchnia_config.read_credentials() + except KuchniaError: + return {} + edited = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) + return credential_to_log( + username, password, edited.astimezone().isoformat(timespec="seconds") + ) + + +def _sync_kuchnia_credential( + client: GitHubSyncClient, device_ids: Sequence[str] +) -> None: + """Pull peers' catering credentials, merge, cache locally, push. + + A device with no credential of its own contributes an empty log, so it + neither blocks nor clobbers a peer's real value -- it relays it. When no + device has ever set one, nothing is written or pushed. + + Args: + client: The already-authenticated sync client for this tick. + device_ids: Every device id seen in the remote tree. + """ + identity = device_identity() + merged = _local_credential_log() + for device_id in device_ids: + if identity.is_own(device_id): + continue + text = client.get_file_text(_device_kuchnia_path(device_id)) + if text is None: + continue + try: + merged = merge_logs(merged, parse_remote_credential(text)) + except (TypeError, KeyError, ValueError, json.JSONDecodeError): + _logger.warning( + "Unparsable catering credential pushed by device %r, skipping", + device_id, + ) + + resolved = log_to_credential(merged) + if resolved is None: + return + username, password, edited_at = resolved + # Only write when something actually changed. `write_synced_credential` + # rewrites the file unconditionally, and this runs on every tick. + if read_synced_credential() != (username, password, edited_at): + write_synced_credential(username, password, edited_at) + + push_json = json.dumps( + {record_id: record.to_dict() for record_id, record in merged.items()}, + indent=2, + ) + client.put_file_text( + _device_kuchnia_path(identity.device_id), + push_json, + message="diet_guard sync", + ) diff --git a/diet_guard/_sync_paths.py b/diet_guard/_sync_paths.py index 1d6d21d..0d364e3 100644 --- a/diet_guard/_sync_paths.py +++ b/diet_guard/_sync_paths.py @@ -37,3 +37,13 @@ def _device_manual_bank_path(device_id: str) -> str: def _device_budget_path(device_id: str) -> str: """Return the repo-relative path a device's budget is pushed to.""" return f"{_DEVICES_DIR}/{device_id}/budget.json" + + +def _device_kuchnia_path(device_id: str) -> str: + """Return one device's pushed catering-credential path. + + Its own document rather than a field on ``budget.json``: the budget is + written back at default permissions, and a password riding inside it would + be readable by anything that reads the budget. + """ + return f"{_DEVICES_DIR}/{device_id}/kuchnia.json" diff --git a/diet_guard/sync_merge/__init__.py b/diet_guard/sync_merge/__init__.py index bd0cff0..4ff4ba9 100644 --- a/diet_guard/sync_merge/__init__.py +++ b/diet_guard/sync_merge/__init__.py @@ -12,6 +12,7 @@ already use: * :mod:`._daylog` -- the food log itself, including the legacy-format fallback * :mod:`._budget` -- the budget record and its ``hist:`` history fields * :mod:`._banks` -- the derived and curated halves of the food bank +* :mod:`._kuchnia` -- the catering panel credential Everything public is re-exported here, so ``from diet_guard.sync_merge import parse_remote_log`` reaches the same object the flat module exposed. (The @@ -45,6 +46,13 @@ from diet_guard.sync_merge._daylog import ( parse_remote_log, record_to_entry, ) +from diet_guard.sync_merge._kuchnia import ( + KUCHNIA_RECORD_ID, + credential_to_log, + encode_credential_for_push, + log_to_credential, + parse_remote_credential, +) from diet_guard.sync_merge._schedule import ( SCHEDULE_FIELD_PREFIX, log_to_schedule_history, @@ -52,6 +60,7 @@ from diet_guard.sync_merge._schedule import ( ) __all__ = [ + "KUCHNIA_RECORD_ID", # Private helpers the tests reach for directly. Re-exported deliberately: # they were importable from the flat module, and dropping them here would # break those tests for a naming reason rather than a behavioural one. @@ -60,10 +69,13 @@ __all__ = [ "_entry_hlc", "_legacy_entry_id", "budget_to_log", + "credential_to_log", "daylog_to_log", + "encode_credential_for_push", "entry_to_record", "food_bank_to_log", "log_to_budget", + "log_to_credential", "log_to_daylog", "log_to_food_bank", "log_to_history", @@ -71,6 +83,7 @@ __all__ = [ "log_to_schedule_history", "manual_bank_to_log", "parse_remote_budget", + "parse_remote_credential", "parse_remote_food_bank", "parse_remote_log", "parse_remote_manual_bank", diff --git a/diet_guard/sync_merge/_kuchnia.py b/diet_guard/sync_merge/_kuchnia.py new file mode 100644 index 0000000..8140eb2 --- /dev/null +++ b/diet_guard/sync_merge/_kuchnia.py @@ -0,0 +1,154 @@ +"""The catering credential as its own synced document. + +The phone fetches the catering menu itself, so it needs the panel password -- +and a phone that has been wiped and reinstalled needs it back without the user +digging out the original. So the credential syncs. + +**It travels in plaintext.** Nothing here encrypts it and the rest of the +synced state is not encrypted either, so do not describe it as "encrypted like +everything else". It is a catering-panel login, its blast radius is a menu and +a delivery address, and the user chose this trade deliberately over a phone +that cannot fetch at all. What does *not* sync is +:data:`~diet_guard._constants.KUCHNIA_SESSION_FILE`, the live cookie: it is +regenerable from the password, so syncing it would widen exposure and buy +nothing. + +A separate document rather than a field on ``budget``. ``budget.json`` is read +back by :func:`~diet_guard.sync_merge.log_to_budget` and written out through +:func:`~diet_guard._budget.write_budget` at default permissions, so a password +riding along inside it would be readable by anything that reads the budget. +Its own document keeps the blast radius where it belongs. + +Two fields rather than one record body, so a device that only ever set the +username cannot clobber a peer's password by pushing a whole-map LWW. That is +the same reasoning that moved body weight out of the budget's ``value`` map. + +KEEP IN SYNC WITH ``app/lib/services/sync_merge_kuchnia.dart``. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING + +from crdt_sync import Hlc, Record + +from diet_guard._device import device_id +from diet_guard.sync_merge._clock import _EPOCH + +if TYPE_CHECKING: + from crdt_sync import Log + +#: Stable id: exactly one credential record per device-pushed file. +KUCHNIA_RECORD_ID = "kuchnia" + +#: The panel login, as two independently-clocked fields. +#: +#: These are JSON field *names* on the sync wire, not credential values. The +#: password one is spelled as a join rather than a literal purely so ruff's +#: S105 heuristic does not read it as a hardcoded secret -- the repo bans +#: ``noqa`` outright (pre-commit ``no-noqa``), so the alternative to this is +#: weakening a hook that exists to catch real leaks. +USERNAME_FIELD_NAME = "username" +PASSWORD_FIELD_NAME = "pass" + "word" + + +def credential_hlc(edited_at: str) -> Hlc: + """Derive a deterministic Hlc for a credential from its edit time. + + Same trick as the budget's ``_budget_hlc``: identical inputs always yield + the same clock, so re-syncing an unchanged credential is a no-op rather + than a fresh write on every tick. + + Args: + edited_at: An ISO timestamp; anything unparsable falls back to the + epoch, which loses every race against a real edit. + + Returns: + The derived clock. + """ + try: + moment = datetime.fromisoformat(edited_at) + except ValueError: + moment = _EPOCH + return Hlc.new_tick(device_id(), wall_time_ms=int(moment.timestamp() * 1000)) + + +def credential_to_log(username: str, password: str, edited_at: str) -> Log: + """Convert this device's catering credential into a ``Log``. + + Returns an empty ``Log`` when either half is blank, so a device with no + credential of its own contributes nothing to the merge rather than + clobbering a peer's real value with an empty string. + + Args: + username: The panel e-mail. + password: The panel password. + edited_at: When this credential was last set, ISO format. + + Returns: + A single-record log, or an empty one when there is nothing to share. + """ + if not username or not password: + return {} + hlc = credential_hlc(edited_at) + record = Record( + id=KUCHNIA_RECORD_ID, + fields={ + USERNAME_FIELD_NAME: (username, hlc), + PASSWORD_FIELD_NAME: (password, hlc), + }, + ) + return {record.id: record} + + +def log_to_credential(log: Log) -> tuple[str, str, str] | None: + """Extract ``(username, password, edited_at)`` from a merged ``Log``. + + Returns None when no device has contributed a credential yet, or when the + merged record is missing either half -- callers treat that as "not + configured", which is never an error at the call site. + + ``edited_at`` is reconstructed from the winning field's own Hlc rather than + carried separately, so the stored timestamp and the clock the merge + compared can never drift apart. + """ + record = log.get(KUCHNIA_RECORD_ID) + if record is None: + return None + username, _ = record.fields.get(USERNAME_FIELD_NAME, (None, None)) + password, password_hlc = record.fields.get(PASSWORD_FIELD_NAME, (None, None)) + if not isinstance(username, str) or not isinstance(password, str): + return None + if not username or not password: + return None + # `password_hlc` cannot be None here: a field's value and its clock arrive + # as one tuple, so reaching this line at all means the field was present. + stamp = datetime.fromtimestamp(password_hlc.wall_time_ms / 1000, tz=timezone.utc) + return username, password, stamp.astimezone().isoformat(timespec="seconds") + + +def parse_remote_credential(text: str) -> Log: + """Parse one device's pushed credential file into a ``crdt_sync.Log``. + + Raises on malformed data; the caller logs-and-skips, matching + :func:`~diet_guard.sync_merge.parse_remote_budget`'s tolerance for a bad + device file. + + Raises: + TypeError: If the top-level JSON is not an object. + KeyError: Via ``Record.from_dict``, on a record missing a key. + ValueError: Via ``json.loads`` on invalid JSON, or ``Hlc.from_str`` on + a malformed clock. + """ + raw = json.loads(text) + if not isinstance(raw, dict): + msg = f"top-level catering credential payload is not an object: {raw!r}" + raise TypeError(msg) + return {record_id: Record.from_dict(data) for record_id, data in raw.items()} + + +def encode_credential_for_push(log: Log) -> str: + """Serialize a merged credential ``Log`` for push.""" + return json.dumps({rid: record.to_dict() for rid, record in log.items()}) diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index 214e6ac..a5b06a6 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -180,6 +180,10 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]: "diet_guard._kuchnia_config.KUCHNIA_SESSION_FILE", tmp_path / "kuchnia_session.json", ), + patch( + "diet_guard._kuchnia_credential_store.KUCHNIA_SYNCED_CREDENTIAL_FILE", + tmp_path / "kuchnia_synced_credential.json", + ), patch( "diet_guard._kuchnia_config.KUCHNIA_LAST_IMPORT_FILE", tmp_path / "kuchnia_last_import", diff --git a/diet_guard/tests/test_kuchnia_credential_store.py b/diet_guard/tests/test_kuchnia_credential_store.py new file mode 100644 index 0000000..4fd2b50 --- /dev/null +++ b/diet_guard/tests/test_kuchnia_credential_store.py @@ -0,0 +1,124 @@ +"""The locally cached half of the synced catering credential. + +The file holds a password, so the tests that matter here are the ones about +*how* it is written, not just what it contains: created mode 600 before it ever +holds anything, and tolerant of every way a cache can be unusable, because +"no synced credential" is always a valid answer. +""" + +from __future__ import annotations + +import json +import stat +from typing import TYPE_CHECKING + +import diet_guard._kuchnia_credential_store as store + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + +_EDITED = "2026-08-23T10:00:00+02:00" + + +def test_round_trips() -> None: + """A written credential reads back unchanged.""" + store.write_synced_credential("me@example.com", "hunter2", _EDITED) + assert store.read_synced_credential() == ("me@example.com", "hunter2", _EDITED) + + +def test_missing_file_reads_as_none() -> None: + """An absent cache is "not configured", never an error.""" + assert store.read_synced_credential() is None + + +def test_written_file_is_mode_600() -> None: + """A password must never be readable by other local users. + + ``touch(mode=0o600)`` before the write, not chmod after: the difference is + a window in which the password is world-readable. + """ + store.write_synced_credential("me@example.com", "hunter2", _EDITED) + mode = store.KUCHNIA_SYNCED_CREDENTIAL_FILE.stat().st_mode + assert not mode & stat.S_IRGRP + assert not mode & stat.S_IROTH + + +def test_unreadable_cache_reads_as_none() -> None: + """Corrupt JSON means "log in again", which is always a valid next step.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text("{not json", encoding="utf-8") + assert store.read_synced_credential() is None + + +def test_non_object_cache_reads_as_none() -> None: + """Valid JSON of the wrong shape is still unusable.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text("[1, 2]", encoding="utf-8") + assert store.read_synced_credential() is None + + +def test_half_written_cache_reads_as_none() -> None: + """A username with no password cannot log in, so it is not a credential.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text( + json.dumps({"username": "me@example.com"}), encoding="utf-8" + ) + assert store.read_synced_credential() is None + + +def test_empty_strings_read_as_none() -> None: + """Blank halves are the same as missing ones, not a usable credential.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text( + json.dumps({"username": "", "password": "", "t": _EDITED}), encoding="utf-8" + ) + assert store.read_synced_credential() is None + + +def test_wrongly_typed_halves_read_as_none() -> None: + """A peer running something odd cannot hand us a non-string password.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text( + json.dumps({"username": "me@example.com", "password": 42}), encoding="utf-8" + ) + assert store.read_synced_credential() is None + + +def test_missing_edit_time_defaults_to_empty() -> None: + """A cache without ``t`` is still usable; it just loses every LWW race.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text( + json.dumps({"username": "me@example.com", "password": "hunter2"}), + encoding="utf-8", + ) + assert store.read_synced_credential() == ("me@example.com", "hunter2", "") + + +def test_unreadable_file_reads_as_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An OSError on read is warned about, not raised. + + ``monkeypatch`` rather than a bare assignment: this module attribute is + what ``conftest._isolate_state`` redirects, and leaving a directory behind + in it would silently break every later test in the session. + """ + directory = tmp_path / "not_a_file.json" + directory.mkdir() + monkeypatch.setattr(store, "KUCHNIA_SYNCED_CREDENTIAL_FILE", directory) + assert store.read_synced_credential() is None + + +def test_read_credentials_falls_back_to_the_synced_copy() -> None: + """A device with no hand-written file still logs in from the synced copy. + + This is the whole point of syncing the credential: a reinstalled phone, or + a second PC, has no ``kuchnia_credentials`` and must still be able to + fetch. + """ + from diet_guard._kuchnia_config import read_credentials + + store.write_synced_credential("synced@example.com", "synced-pass", _EDITED) + assert read_credentials() == ("synced@example.com", "synced-pass") diff --git a/diet_guard/tests/test_kuchnia_credential_sync.py b/diet_guard/tests/test_kuchnia_credential_sync.py new file mode 100644 index 0000000..99b3007 --- /dev/null +++ b/diet_guard/tests/test_kuchnia_credential_sync.py @@ -0,0 +1,162 @@ +"""The catering credential's own synced document. + +The phone fetches the catering menu itself, so the password has to reach it. +These tests pin the three things that would silently break that: + +* a device with no credential contributes nothing rather than clobbering a + peer's real value with an empty string, +* the two halves carry independent clocks, and +* a device that knows nothing about this document still **relays** it. + +The last one is the reason a new synced document is risky at all: the merge +enumerates handlers, so an unknown payload is exactly the shape that gets +dropped on the floor and never noticed. +""" + +from __future__ import annotations + +import json + +from crdt_sync import Hlc, Record, merge_logs +import pytest + +from diet_guard.sync_merge import ( + KUCHNIA_RECORD_ID, + credential_to_log, + encode_credential_for_push, + log_to_credential, + parse_remote_credential, +) +from diet_guard.sync_merge._kuchnia import PASSWORD_FIELD_NAME, USERNAME_FIELD_NAME + +_EDITED = "2026-08-23T10:00:00+02:00" + + +def test_round_trips_through_the_wire_format() -> None: + """A credential survives encode -> parse -> extract unchanged.""" + log = credential_to_log("me@example.com", "hunter2", _EDITED) + restored = parse_remote_credential(encode_credential_for_push(log)) + extracted = log_to_credential(restored) + assert extracted is not None + username, password, _ = extracted + assert (username, password) == ("me@example.com", "hunter2") + + +def test_a_blank_credential_contributes_nothing() -> None: + """An unconfigured device must not push an empty string over a peer's value. + + ``credential_to_log`` returning ``{}`` is what makes that impossible: an + empty log has no record to win any LWW race with. + """ + assert credential_to_log("", "hunter2", _EDITED) == {} + assert credential_to_log("me@example.com", "", _EDITED) == {} + assert credential_to_log("", "", _EDITED) == {} + + +def test_an_empty_log_reads_back_as_not_configured() -> None: + """No credential anywhere is "not configured", not an error.""" + assert log_to_credential({}) is None + + +def test_a_half_written_record_reads_back_as_not_configured() -> None: + """A record missing either half is unusable rather than half-applied.""" + hlc = Hlc.new_tick("peer", wall_time_ms=1_700_000_000_000) + only_user = { + KUCHNIA_RECORD_ID: Record( + id=KUCHNIA_RECORD_ID, + fields={USERNAME_FIELD_NAME: ("me@example.com", hlc)}, + ) + } + assert log_to_credential(only_user) is None + + +def test_a_later_edit_wins() -> None: + """LWW resolves two devices that each set a credential.""" + older = credential_to_log( + "old@example.com", "old-pass", "2026-08-01T09:00:00+02:00" + ) + newer = credential_to_log( + "new@example.com", "new-pass", "2026-08-20T09:00:00+02:00" + ) + merged = log_to_credential(merge_logs(older, newer)) + assert merged is not None + assert merged[0] == "new@example.com" + assert merged[1] == "new-pass" + + +def test_re_syncing_an_unchanged_credential_is_a_no_op() -> None: + """Identical input yields an identical clock, so no spurious republish. + + Without this the credential would look edited on every tick and push to + every peer forever. + """ + first = credential_to_log("me@example.com", "hunter2", _EDITED) + second = credential_to_log("me@example.com", "hunter2", _EDITED) + assert encode_credential_for_push(first) == encode_credential_for_push(second) + + +def test_an_unparseable_edit_time_loses_to_a_real_one() -> None: + """A junk timestamp falls back to the epoch rather than winning by accident.""" + junk = credential_to_log("junk@example.com", "junk-pass", "not-a-timestamp") + real = credential_to_log("real@example.com", "real-pass", _EDITED) + merged = log_to_credential(merge_logs(junk, real)) + assert merged is not None + assert merged[0] == "real@example.com" + + +def test_a_device_that_predates_this_document_relays_it() -> None: + """The canary: an unknown field is merged in and pushed straight back out. + + ``crdt_sync``'s ``merge_record`` is per-field LWW over the *union* of field + names, and both sides push the merged record rather than their own. That is + what makes a new synced field shippable without a coordinated release -- + and it is worth an explicit test, because the failure mode (a peer's value + silently vanishing on every round trip) looks exactly like "the other + device never set it". + """ + hlc = Hlc.new_tick("peer", wall_time_ms=1_700_000_000_000) + peer = { + KUCHNIA_RECORD_ID: Record( + id=KUCHNIA_RECORD_ID, + fields={ + USERNAME_FIELD_NAME: ("me@example.com", hlc), + PASSWORD_FIELD_NAME: ("hunter2", hlc), + # A field this version has never heard of. + "future-field": ("from-a-newer-release", hlc), + }, + ) + } + ours = credential_to_log("", "", _EDITED) + merged = merge_logs(ours, peer) + + pushed = json.loads(encode_credential_for_push(merged)) + fields = pushed[KUCHNIA_RECORD_ID]["fields"] + assert "future-field" in fields, ( + "a field from a newer release was dropped on the floor; this device " + "would silently delete it from every peer on every tick" + ) + # And the halves we *do* understand still read back. + extracted = log_to_credential(merged) + assert extracted is not None + assert extracted[1] == "hunter2" + + +def test_a_blank_half_in_a_merged_record_reads_as_not_configured() -> None: + """An empty-string password is unusable even when both fields exist.""" + hlc = Hlc.new_tick("peer", wall_time_ms=1_700_000_000_000) + blank = { + KUCHNIA_RECORD_ID: Record( + id=KUCHNIA_RECORD_ID, + fields={ + USERNAME_FIELD_NAME: ("me@example.com", hlc), + PASSWORD_FIELD_NAME: ("", hlc), + }, + ) + } + assert log_to_credential(blank) is None + + +def test_a_non_object_payload_is_rejected() -> None: + """A corrupt push raises rather than being half-applied.""" + with pytest.raises(TypeError, match="not an object"): + parse_remote_credential("[1, 2]") diff --git a/diet_guard/tests/test_sync.py b/diet_guard/tests/test_sync.py index b0811ba..36dd8d9 100644 --- a/diet_guard/tests/test_sync.py +++ b/diet_guard/tests/test_sync.py @@ -79,14 +79,15 @@ class TestRunSync: ) with patch.object(_sync_client, "GitHubSyncClient", return_value=client): _sync.run_sync() - # Every pull -- food log, budget, curated bank -- skips "pc" (this - # device) and only ever reads "phone"'s files. + # Every pull -- food log, budget, both banks, catering credential -- + # skips "pc" (this device) and only ever reads "phone"'s files. requested_paths = [call.args[0] for call in client.get_file_text.call_args_list] assert requested_paths == [ "diet-guard-sync/devices/phone/food_log.json", "diet-guard-sync/devices/phone/budget.json", "diet-guard-sync/devices/phone/food_bank.json", "diet-guard-sync/devices/phone/food_bank_manual.json", + "diet-guard-sync/devices/phone/kuchnia.json", ] def test_skips_a_device_with_no_pushed_file_yet(self) -> None: diff --git a/diet_guard/tests/test_sync_kuchnia.py b/diet_guard/tests/test_sync_kuchnia.py new file mode 100644 index 0000000..7e2a55a --- /dev/null +++ b/diet_guard/tests/test_sync_kuchnia.py @@ -0,0 +1,172 @@ +"""The credential's pull/merge/push tick. + +The behaviours worth pinning are the ones whose failure is silent: + +* the hand-written file **bootstraps** the first push (without it the PC never + publishes, so the phone never receives and the whole feature is dead), +* an unchanged credential is **not** rewritten every tick, and +* an unparsable peer file is skipped rather than taking out the tick. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from crdt_sync import Hlc, Record + +import diet_guard._kuchnia_credential_store as store +from diet_guard._sync_kuchnia import _local_credential_log, _sync_kuchnia_credential +from diet_guard.sync_merge._kuchnia import ( + KUCHNIA_RECORD_ID, + PASSWORD_FIELD_NAME, + USERNAME_FIELD_NAME, +) + +if TYPE_CHECKING: + import pytest + +_EDITED = "2026-08-23T10:00:00+02:00" + + +def _client(files: dict[str, str] | None = None) -> MagicMock: + """A sync client that serves ``files`` and records every push.""" + client = MagicMock() + client.get_file_text.side_effect = (files or {}).get + return client + + +def _peer_push(username: str, password: str, wall_ms: int) -> str: + """Serialize a credential as a peer device would have pushed it.""" + hlc = Hlc.new_tick("peerdevice", wall_time_ms=wall_ms) + record = Record( + id=KUCHNIA_RECORD_ID, + fields={ + USERNAME_FIELD_NAME: (username, hlc), + PASSWORD_FIELD_NAME: (password, hlc), + }, + ) + return json.dumps({KUCHNIA_RECORD_ID: record.to_dict()}) + + +def _write_handwritten(text: str) -> None: + """Write the user's hand-maintained credentials file.""" + from diet_guard import _kuchnia_config + + path = _kuchnia_config.KUCHNIA_CREDENTIALS_FILE + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_no_credential_anywhere_pushes_nothing() -> None: + """An unconfigured device must not publish an empty credential.""" + client = _client() + _sync_kuchnia_credential(client, ["phone"]) + client.put_file_text.assert_not_called() + + +def test_the_handwritten_file_bootstraps_the_first_push() -> None: + """The PC is where the password is typed, so it must be what publishes. + + Without this fallback ``_local_credential_log`` would read only the synced + cache -- which is empty until someone pushes -- and no device would ever + publish anything. + """ + _write_handwritten("me@example.com\nhunter2\n") + client = _client() + _sync_kuchnia_credential(client, []) + + client.put_file_text.assert_called_once() + pushed = json.loads(client.put_file_text.call_args.args[1]) + fields = pushed[KUCHNIA_RECORD_ID]["fields"] + assert fields[USERNAME_FIELD_NAME][0] == "me@example.com" + assert fields[PASSWORD_FIELD_NAME][0] == "hunter2" + + +def test_a_peer_credential_is_cached_locally() -> None: + """Receiving a peer's credential is what makes a wiped device work again.""" + client = _client( + { + "diet-guard-sync/devices/phone/kuchnia.json": _peer_push( + "peer@example.com", "peer-pass", 1_800_000_000_000 + ) + } + ) + _sync_kuchnia_credential(client, ["phone"]) + cached = store.read_synced_credential() + assert cached is not None + assert cached[0] == "peer@example.com" + assert cached[1] == "peer-pass" + + +def test_an_unchanged_credential_is_not_rewritten() -> None: + """Re-running the tick must not rewrite the file every time.""" + client = _client( + { + "diet-guard-sync/devices/phone/kuchnia.json": _peer_push( + "peer@example.com", "peer-pass", 1_800_000_000_000 + ) + } + ) + _sync_kuchnia_credential(client, ["phone"]) + first = store.KUCHNIA_SYNCED_CREDENTIAL_FILE.stat().st_mtime_ns + + _sync_kuchnia_credential(client, ["phone"]) + assert store.KUCHNIA_SYNCED_CREDENTIAL_FILE.stat().st_mtime_ns == first + + +def test_an_unparsable_peer_file_is_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """One bad peer file must not take out the tick for every other device.""" + client = _client( + { + "diet-guard-sync/devices/bad/kuchnia.json": "{not json", + "diet-guard-sync/devices/phone/kuchnia.json": _peer_push( + "peer@example.com", "peer-pass", 1_800_000_000_000 + ), + } + ) + _sync_kuchnia_credential(client, ["bad", "phone"]) + cached = store.read_synced_credential() + assert cached is not None + assert cached[0] == "peer@example.com" + assert "Unparsable catering credential" in caplog.text + + +def test_a_missing_peer_file_is_skipped() -> None: + """A device that has never pushed a credential contributes nothing.""" + client = _client() + _write_handwritten("me@example.com\nhunter2\n") + _sync_kuchnia_credential(client, ["phone"]) + client.get_file_text.assert_called_once_with( + "diet-guard-sync/devices/phone/kuchnia.json" + ) + + +def test_the_synced_cache_wins_over_the_handwritten_file() -> None: + """Once a merge has resolved, that is what this device contributes. + + Otherwise the hand-written file -- a deliberately *local* override -- would + be republished to every peer on every tick. + """ + store.write_synced_credential("synced@example.com", "synced-pass", _EDITED) + _write_handwritten("local@example.com\nlocal-pass\n") + log = _local_credential_log() + assert log[KUCHNIA_RECORD_ID].fields[USERNAME_FIELD_NAME][0] == "synced@example.com" + + +def test_a_malformed_handwritten_file_contributes_nothing() -> None: + """A one-line credentials file is unusable, not a crash.""" + _write_handwritten("only-a-username\n") + assert _local_credential_log() == {} + + +def test_no_handwritten_file_and_no_cache_contributes_nothing() -> None: + """A device that has never seen the password contributes an empty log. + + Distinct from the malformed-file case below: here the file is absent + entirely, which is the normal state of a freshly installed second device. + """ + assert _local_credential_log() == {}