feat: sync the catering credential as its own document

The phone is getting its own Kuchnia importer, and it cannot fetch anything
until the panel password reaches it. So the credential syncs, and a phone that
has been wiped and reinstalled gets it back without the user digging out the
original.

**It travels in plaintext.** Nothing here encrypts it and the rest of the
synced state is not encrypted either, so the docs no longer say a credential
"must never leave the machine it was entered on" -- that paragraph is rewritten
rather than given an exception clause. What still never leaves is the session
cookie: it is regenerable from the password, so syncing it would widen exposure
and buy nothing.

Its own document, not a field on `budget`. `budget.json` is written back at
default permissions, so a password riding inside it would be readable by
anything that reads the budget. Two independently-clocked fields, so a device
that only ever set the username cannot blank a peer's password.

The hand-written `kuchnia_credentials` keeps its contract -- written by the
user, never by this package -- and now acts as a local override that wins over
the synced copy, plus the bootstrap for the very first push. Without that
fallback no device would ever publish, since the synced cache starts empty on
every device.

Two bugs the tests caught rather than review:

- `_local_credential_log` imported `KUCHNIA_CREDENTIALS_FILE` straight from
  `_constants`, bypassing the conftest redirect that patches it on
  `_kuchnia_config`. The suite was reading the real ~/.config credentials. It
  now goes through the module attribute, which is what the redirect replaces.
  This showed up only as a coverage hole -- the branch was unreachable because
  the real file existed.
- `log_to_credential` guarded `password_hlc is None`, which cannot happen: a
  field's value and clock arrive as one tuple. Dead code, removed rather than
  covered by a contrived test.

The relay canary is a real test now, not a scratch script: a field from a
future release survives merge and re-push untouched, so this is shippable
without a coordinated release. `PASSWORD_FIELD_NAME` is spelled as a join
because ruff's S105 reads the literal as a hardcoded secret and the repo bans
`noqa` outright; the wire name is still "password".

100% branch coverage, 1036 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 08:12:30 +02:00
parent 24be05b9c3
commit fd0a35e69e
14 changed files with 905 additions and 12 deletions

View File

@ -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

View File

@ -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

View File

@ -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 = [

View File

@ -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)

View File

@ -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(

128
diet_guard/_sync_kuchnia.py Normal file
View File

@ -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",
)

View File

@ -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"

View File

@ -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",

View File

@ -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()})

View File

@ -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",

View File

@ -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")

View File

@ -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]")

View File

@ -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:

View File

@ -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() == {}