Detect a Firebase credential the server rejects, not just one that fails to build

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 21:32:39 +02:00
parent fa8fa208d8
commit 06e48a7dc2
20 changed files with 704 additions and 170 deletions

View File

@ -43,8 +43,8 @@ if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from screen_locker._degraded_sources import DegradedSource
from screen_locker._sick_tracker import SickHistory from screen_locker._sick_tracker import SickHistory
from screen_locker._sync_client import DegradedSource
__all__ = [ __all__ = [
"AutoUpgradeOpportunity", "AutoUpgradeOpportunity",

View File

@ -15,7 +15,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence
from screen_locker._sync_client import DegradedSource from screen_locker._degraded_sources import DegradedSource
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -46,20 +46,28 @@ class RecoveryResult:
donor: str | None = None donor: str | None = None
def find_sibling_refresh_token( def find_sibling_refresh_tokens(
config_root: Path, *, skip: str config_root: Path, *, skip: str
) -> tuple[str, str] | None: ) -> list[tuple[str, str]]:
"""Return ``(app_name, refresh_token)`` from a sibling app's cache. """Return every ``(app_name, refresh_token)`` a sibling app has cached.
All of them, not just the first: the caches on this machine are of mixed
vintages (some minted weeks apart), so the alphabetically-first donor is
not necessarily a live one. Returning the whole list lets
:func:`recover_session` try the next one instead of giving up on a single
stale token -- the same "one dead source blocks the healthy ones" bug this
module exists to fix, one level down.
Args: Args:
config_root: The directory holding per-app config dirs (``~/.config``). config_root: The directory holding per-app config dirs (``~/.config``).
skip: Our own app name, whose cache is the thing being replaced. skip: Our own app name, whose cache is the thing being replaced.
Returns: Returns:
The first usable sibling credential, or ``None`` when none exists. Every usable sibling credential, in a stable order.
""" """
if not config_root.is_dir(): if not config_root.is_dir():
return None return []
found: list[tuple[str, str]] = []
for cache in sorted(config_root.glob(f"*/{_CACHE_NAME}")): for cache in sorted(config_root.glob(f"*/{_CACHE_NAME}")):
app_name = cache.parent.name app_name = cache.parent.name
if app_name == skip: if app_name == skip:
@ -77,8 +85,8 @@ def find_sibling_refresh_token(
continue continue
token = data.get("refresh_token") token = data.get("refresh_token")
if isinstance(token, str) and token: if isinstance(token, str) and token:
return app_name, token found.append((app_name, token))
return None return found
def recover_session( def recover_session(
@ -97,8 +105,8 @@ def recover_session(
app_name: Our app name, both the skip target and the cache we write. app_name: Our app name, both the skip target and the cache we write.
mint: Exchanges a refresh token for a session and persists it. mint: Exchanges a refresh token for a session and persists it.
""" """
found = find_sibling_refresh_token(config_root, skip=app_name) candidates = find_sibling_refresh_tokens(config_root, skip=app_name)
if found is None: if not candidates:
reason = ( reason = (
f"no sibling app under {config_root} holds a Firebase refresh " f"no sibling app under {config_root} holds a Firebase refresh "
"token, so the session cannot be rebuilt locally — sign in once " "token, so the session cannot be rebuilt locally — sign in once "
@ -107,14 +115,40 @@ def recover_session(
_logger.warning("Firebase recovery failed: %s", reason) _logger.warning("Firebase recovery failed: %s", reason)
return RecoveryResult(recovered=False, reason=reason) return RecoveryResult(recovered=False, reason=reason)
donor, refresh_token = found rejections: list[str] = []
try: donor = ""
mint(refresh_token) for candidate, refresh_token in candidates:
except (OSError, ValueError, RuntimeError, ConfigError, RemoteSyncError) as exc: try:
reason = f"borrowed {donor}'s refresh token but it was rejected: {exc}" mint(refresh_token)
except (OSError, ValueError, RuntimeError, ConfigError, RemoteSyncError) as exc:
# Keep going: a stale donor says nothing about the next one, and
# stopping here is how a single expired sibling blocked a recovery
# that several live credentials could have completed. Each
# rejection is logged as it happens, so a donor that has quietly
# gone stale is visible even on the runs that recover anyway.
_logger.warning(
"Sibling %s's refresh token was rejected (%s) — trying the "
"next donor; %s should be reseeded",
candidate,
exc,
candidate,
)
rejections.append(f"{candidate} ({exc})")
continue
donor = candidate
break
else:
reason = "every sibling refresh token was rejected: " + ", ".join(rejections)
_logger.warning("Firebase recovery failed: %s", reason) _logger.warning("Firebase recovery failed: %s", reason)
return RecoveryResult(recovered=False, reason=reason) return RecoveryResult(recovered=False, reason=reason)
if rejections:
_logger.warning(
"Firebase recovery fell through to %s after %s was rejected — the "
"earlier credential is stale and should be reseeded",
donor,
", ".join(rejections),
)
_logger.info( _logger.info(
"Firebase session rebuilt for %s from %s's cached refresh token — no " "Firebase session rebuilt for %s from %s's cached refresh token — no "
"password or human step was needed", "password or human step was needed",

View File

@ -0,0 +1,50 @@
"""Which workout backends could not be read on this run, and why.
Split out of :mod:`screen_locker._sync_client` to keep every file under the
250-line cap. Re-exported from there, so existing importers and their patch
targets are unchanged.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class DegradedSource:
"""A workout backend that could not be read on this run.
``reason`` is the backend's own error text, kept verbatim so the status
view and the lock decision can quote something actionable rather than a
generic "sync problem".
"""
name: str
reason: str
# Process-scoped rather than persisted: it describes THIS run's ability to
# read, and a stale marker from an earlier run would be worse than none. The
# lock chain and the pull both happen in one process, so this survives exactly
# as long as it is true.
_degraded: list[DegradedSource] = []
def degraded_sources() -> list[DegradedSource]:
"""Return the backends that failed to answer during this run."""
return list(_degraded)
def clear_degraded_sources() -> None:
"""Forget recorded failures (called at the start of a fresh check)."""
_degraded.clear()
def _record_degraded(name: str, reason: str) -> None:
"""Note that *name* could not be read, so callers can stop guessing.
Logging alone was not enough: on 2026-08-24 the warning was emitted, the
Firebase read was skipped, and the lock decision still reported
"0 workouts this week" as though the source had answered "none".
"""
_degraded.append(DegradedSource(name, reason))

View File

@ -51,6 +51,7 @@ from screen_locker._constants import (
from screen_locker._device import device_identity from screen_locker._device import device_identity
from screen_locker._log_io import load_workout_log from screen_locker._log_io import load_workout_log
from screen_locker._log_mixin import _derive_workout_id from screen_locker._log_mixin import _derive_workout_id
from screen_locker._push_outcome import describe_push
from screen_locker._sync_retry import with_sync_retry from screen_locker._sync_retry import with_sync_retry
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client
@ -238,7 +239,7 @@ def push_pc_workouts(log_file: Path) -> PushResult:
) )
return PushResult(pushed=False, record_count=len(log), reason=reason) return PushResult(pushed=False, record_count=len(log), reason=reason)
_logger.info( # Delegated so a half-landed push can never be logged as a clean one;
"Synced %d workout(s) to %s/%s", len(log), SYNC_REPO_OWNER, SYNC_REPO_NAME # see _push_outcome for why that distinction is load-bearing.
) _, reason = describe_push(len(log))
return PushResult(pushed=True, record_count=len(log), reason="pushed") return PushResult(pushed=True, record_count=len(log), reason=reason)

View File

@ -0,0 +1,54 @@
"""Turning a completed push into an honest result.
Split out of :mod:`screen_locker._manual_push` to keep every file under the
250-line cap.
A push that the primary backend refused still reaches the GitHub mirror, and
the records are not lost -- the phone reads the union of both. What must not
happen is that half-landed push logging the same line as a whole one, which is
how weeks of broken Firebase pushes looked healthy in the journal.
"""
from __future__ import annotations
import logging
from screen_locker._constants import SYNC_REPO_NAME, SYNC_REPO_OWNER
from screen_locker._degraded_sources import degraded_sources
_logger = logging.getLogger(__name__)
def describe_push(record_count: int) -> tuple[bool, str]:
"""Report whether every backend received the records, and say so.
Args:
record_count: How many records the push carried.
Returns:
``(complete, reason)`` -- ``reason`` reads like a sentence either way,
because it is what ``PushResult`` carries back to the caller.
"""
missing = [source.name for source in degraded_sources()]
if not missing:
_logger.info(
"Synced %d workout(s) to %s/%s",
record_count,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
return True, "pushed"
names = ", ".join(missing)
reason = (
f"pushed to {SYNC_REPO_OWNER}/{SYNC_REPO_NAME} only; "
f"{names} did NOT receive these records"
)
_logger.warning(
"Workout sync push INCOMPLETE for %d workout(s): %s — the phone reads "
"both backends, so this still syncs, but %s is broken and must be "
"fixed before it is the only path left",
record_count,
reason,
names,
)
return False, reason

View File

@ -21,7 +21,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from screen_locker._sync_client import degraded_sources from screen_locker._degraded_sources import degraded_sources
# A phone that has synced nothing for this long is not "quiet", it is broken. # A phone that has synced nothing for this long is not "quiet", it is broken.
# Two days spans a rest day plus a travel day without crying wolf, while still # Two days spans a rest day plus a travel day without crying wolf, while still

View File

@ -26,9 +26,9 @@ from screen_locker._decision_log import (
LockDecision, LockDecision,
record_decision, record_decision,
) )
from screen_locker._degraded_sources import degraded_sources
from screen_locker._extra_benefits import process_week_transition from screen_locker._extra_benefits import process_week_transition
from screen_locker._shutdown_base import reset_to_base_if_new_day from screen_locker._shutdown_base import reset_to_base_if_new_day
from screen_locker._sync_client import degraded_sources
from screen_locker._sync_mixin import SyncMixin from screen_locker._sync_mixin import SyncMixin
from screen_locker._temperature import fetch_current_temp_with_status from screen_locker._temperature import fetch_current_temp_with_status
from screen_locker._weekly_check import ( from screen_locker._weekly_check import (

View File

@ -10,7 +10,6 @@ because a silent None here is exactly how the PC stopped syncing for weeks.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import logging import logging
from pathlib import Path from pathlib import Path
@ -37,48 +36,28 @@ from screen_locker._constants import (
SYNC_TOKEN_FILE, SYNC_TOKEN_FILE,
) )
from screen_locker._credential_recovery import RecoveryResult, recover_session from screen_locker._credential_recovery import RecoveryResult, recover_session
from screen_locker._degraded_sources import (
DegradedSource,
_record_degraded,
clear_degraded_sources,
degraded_sources,
)
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Re-exported: these moved to _degraded_sources for the 250-line cap, but
@dataclass(frozen=True) # callers and tests still reach them as ``_sync_client.<name>``. __all__ keeps
class DegradedSource: # ruff from pruning the imports as unused -- same pattern the tests' conftest
"""A workout backend that could not be read on this run. # uses for its re-exported fixtures.
__all__ = [
``reason`` is the backend's own error text, kept verbatim so the status "DegradedSource",
view and the lock decision can quote something actionable rather than a "clear_degraded_sources",
generic "sync problem". "degraded_sources",
""" "read_sync_token",
"remote_client",
name: str "sync_client",
reason: str "try_recover_firebase_session",
]
# Process-scoped rather than persisted: it describes THIS run's ability to
# read, and a stale marker from an earlier run would be worse than none. The
# lock chain and the pull both happen in one process, so this survives exactly
# as long as it is true.
_degraded: list[DegradedSource] = []
def degraded_sources() -> list[DegradedSource]:
"""Return the backends that failed to answer during this run."""
return list(_degraded)
def clear_degraded_sources() -> None:
"""Forget recorded failures (called at the start of a fresh check)."""
_degraded.clear()
def _record_degraded(name: str, reason: str) -> None:
"""Note that *name* could not be read, so callers can stop guessing.
Logging alone was not enough: on 2026-08-24 the warning was emitted, the
Firebase read was skipped, and the lock decision still reported
"0 workouts this week" as though the source had answered "none".
"""
_degraded.append(DegradedSource(name, reason))
def try_recover_firebase_session() -> RecoveryResult: def try_recover_firebase_session() -> RecoveryResult:
@ -130,15 +109,56 @@ def read_sync_token() -> str | None:
return token or None return token or None
def _live_mirror_client(github: RemoteStore) -> tuple[RemoteStore | None, str]:
"""Build the mirrored client, but only return one that actually answers.
Constructing is not proving. ``mirror_client_for`` only asks
``has_session()``, which reads the cached JSON off disk and never touches
the network, so a credential that is *present but rejected* builds
perfectly and then 401s on every single read and write. That is exactly
what happened on 2026-08-27: the recovery below was wired to construction
failures, the construction succeeded, and so the self-heal never fired
while every operation was being refused.
``can_access_remote`` closes that gap with one authenticated round trip.
It is documented never to raise -- a rejected token, a missing session, a
bad URL and a dead network all report ``False`` -- so a probe failure is
reported as a reason string rather than an exception.
Returns:
``(client, "")`` when Firebase answered, else ``(None, reason)``.
"""
try:
client = mirror_client_for("screen_locker", github)
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
# The reason travels back to the caller, which decides whether to heal
# or degrade -- but it is logged here too, so the originating failure
# is on the record even when a later recovery masks it.
_logger.warning("Could not build the Firebase client: %s", exc)
return None, str(exc)
if not client.can_access_remote():
return None, (
"the cached Firebase credential was rejected by the server — it "
"exists on disk, so nothing failed while connecting, but every "
"read and write is being refused"
)
return client, ""
def remote_client(github: RemoteStore) -> RemoteStore: def remote_client(github: RemoteStore) -> RemoteStore:
"""Return the backend to read the phone's workout log from. """Return the backend to read the phone's workout log from.
Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a
mirror so a phone that has not moved yet is still seen; GitHub alone mirror so a phone that has not moved yet is still seen; GitHub alone
otherwise. Both callers here are read-only pulls -- this side never pushes otherwise. :class:`MirrorSyncClient` reads the union of both, so a workout
-- and :class:`MirrorSyncClient` reads the union of both, so a workout
logged against either backend still counts. logged against either backend still counts.
Not read-only: ``_manual_push.push_pc_workouts`` writes this machine's log
through the client this returns. That matters for the degraded path --
when Firebase is dropped here the push still lands on GitHub, which the
phone also reads, so records are not lost; ``_manual_push`` reports the
push as INCOMPLETE rather than letting a half-landed push look clean.
The config file is checked before constructing anything, so an The config file is checked before constructing anything, so an
unconfigured machine never reaches the network. unconfigured machine never reaches the network.
@ -147,35 +167,34 @@ def remote_client(github: RemoteStore) -> RemoteStore:
""" """
if not CONFIG_FILE.is_file(): if not CONFIG_FILE.is_file():
return github return github
try: usable, reason = _live_mirror_client(github)
return mirror_client_for("screen_locker", github) if usable is not None:
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc: return usable
# Before degrading, try to heal: a sibling app on this machine almost # Before degrading, try to heal: a sibling app on this machine almost
# certainly holds a live refresh token for the same account. Waiting # certainly holds a live refresh token for the same account. Waiting
# for a human to copy a JSON file is what cost 2026-06-12 and # for a human to copy a JSON file is what cost 2026-06-12 and
# 2026-08-24 -- two workouts done, two lockouts anyway. # 2026-08-24 -- two workouts done, two lockouts anyway.
recovery = try_recover_firebase_session() recovery = try_recover_firebase_session()
if recovery.recovered: if recovery.recovered:
try: retried, retry_reason = _live_mirror_client(github)
client = mirror_client_for("screen_locker", github) if retried is not None:
except (ConfigError, FirebaseAuthError, RemoteSyncError) as retry_exc:
_logger.warning(
"Firebase still unusable after %s: %s", recovery.reason, retry_exc
)
_record_degraded("firebase", str(retry_exc))
return github
_logger.info("Firebase recovered automatically: %s", recovery.reason) _logger.info("Firebase recovered automatically: %s", recovery.reason)
return client return retried
_logger.warning( _logger.warning(
"Firebase unavailable, reading workouts via GitHub only: %s — the " "Firebase still unusable after %s: %s", recovery.reason, retry_reason
"phone syncs to Firebase, so a workout logged there is INVISIBLE "
"on this machine until this is fixed. Automatic recovery also "
"failed: %s",
exc,
recovery.reason,
) )
_record_degraded("firebase", f"{exc}; recovery: {recovery.reason}") _record_degraded("firebase", retry_reason)
return github return github
_logger.warning(
"Firebase unavailable, reading workouts via GitHub only: %s — the "
"phone syncs to Firebase, so a workout logged there is INVISIBLE "
"on this machine until this is fixed. Automatic recovery also "
"failed: %s",
reason,
recovery.reason,
)
_record_degraded("firebase", f"{reason}; recovery: {recovery.reason}")
return github
def sync_client() -> RemoteStore | None: def sync_client() -> RemoteStore | None:

View File

@ -0,0 +1,28 @@
"""Helpers for the credential-recovery tests.
Shared because ``test_credential_recovery.py`` had to be split at the
250-line cap and both halves build the same on-disk credential caches.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
def write_cache(path: Path, refresh_token: str, *, expires_at: str) -> None:
"""Write a credential cache shaped like ``FileCredentialStore``'s."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"id_token": "stale-id-token",
"refresh_token": refresh_token,
"expires_at": expires_at,
"local_id": "uid-1",
}
)
)

View File

@ -95,3 +95,40 @@ def _firebase_config(
config.write_text("{}", encoding="utf-8") config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(module, "CONFIG_FILE", config) monkeypatch.setattr(module, "CONFIG_FILE", config)
return config return config
class ReachableClient:
"""A built client whose authenticated probe succeeds.
``remote_client`` no longer trusts a client just because it constructed:
it probes with ``can_access_remote`` first, because a credential that is
present but server-rejected builds fine and then 401s on every operation.
Fakes therefore have to answer that probe. ``identity`` carries whatever
the test wants to assert it got back.
"""
def __init__(self, identity: object = "mirror") -> None:
"""Stand in for a usable backend identified by ``identity``."""
self.identity = identity
def can_access_remote(self) -> bool:
"""Report the backend as usable."""
return True
class RejectedClient:
"""A client that BUILDS fine but is refused by the server.
The shape that caused 2026-08-27: ``has_session()`` only reads the cached
JSON off disk, so a credential the server no longer accepts constructs
without error and then 401s on every operation. Only an authenticated
probe tells the two apart.
"""
def __init__(self, identity: object = "rejected") -> None:
"""Stand in for a backend that refuses this credential."""
self.identity = identity
def can_access_remote(self) -> bool:
"""Report the backend as refusing us."""
return False

View File

@ -129,6 +129,32 @@ def _block_real_network() -> Iterator[None]:
yield yield
@pytest.fixture(autouse=True)
def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Point ``$HOME`` and ``Path.home()`` at tmp_path for every test.
``ISOLATED_STATE`` cannot cover this. Everything under ``~/.config`` is
resolved at *call* time by ``crdt_sync.credential_store_for``
(``Path.home() / ".config" / app / "firebase_auth.json"``), not bound to a
module-level constant, so there is no attribute to patch -- the only seam
is home itself.
What this protects is a live credential, not a scratch file. A test that
reaches ``credential_store_for("screen_locker")`` and saves would overwrite
the machine's real Firebase session with fixture values, and the damage is
invisible until the next sync fails with HTTP 401. The same redirect also
covers the sync PAT and the shared ``~/.config/crdt-sync/`` password and
OAuth secret, which are read the same way.
``Path.home()`` is patched as well as ``$HOME``: it consults ``os.environ``
on POSIX, but only via ``expanduser``, and a test that patches the
environment differently must not silently regain access to the real one.
"""
monkeypatch.setenv("HOME", str(tmp_path))
with patch.object(Path, "home", lambda: tmp_path):
yield
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _isolate_state_files(tmp_path: Path) -> Iterator[None]: def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
"""Redirect every on-disk state path to tmp_path for every test. """Redirect every on-disk state path to tmp_path for every test.

View File

@ -16,9 +16,10 @@ import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from screen_locker._credential_recovery import ( from screen_locker._credential_recovery import (
find_sibling_refresh_token, find_sibling_refresh_tokens,
recover_session, recover_session,
) )
from screen_locker.tests._credential_fixtures import write_cache as _write_cache
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -26,21 +27,6 @@ if TYPE_CHECKING:
import pytest import pytest
def _write_cache(path: Path, refresh_token: str, *, expires_at: str) -> None:
"""Write a credential cache shaped like ``FileCredentialStore``'s."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"id_token": "stale-id-token",
"refresh_token": refresh_token,
"expires_at": expires_at,
"local_id": "uid-1",
}
)
)
class TestFindSiblingRefreshToken: class TestFindSiblingRefreshToken:
"""Locating a live credential already present on this machine.""" """Locating a live credential already present on this machine."""
@ -52,9 +38,9 @@ class TestFindSiblingRefreshToken:
expires_at="2026-08-24T12:00:00+00:00", expires_at="2026-08-24T12:00:00+00:00",
) )
found = find_sibling_refresh_token(tmp_path, skip="screen_locker") found = find_sibling_refresh_tokens(tmp_path, skip="screen_locker")
assert found == ("diet_guard", "refresh-abc") assert found == [("diet_guard", "refresh-abc")]
def test_ignores_our_own_stale_cache(self, tmp_path: Path) -> None: def test_ignores_our_own_stale_cache(self, tmp_path: Path) -> None:
"""Our own dead cache is the thing being replaced, never the source.""" """Our own dead cache is the thing being replaced, never the source."""
@ -64,7 +50,7 @@ class TestFindSiblingRefreshToken:
expires_at="2026-08-24T12:00:00+00:00", expires_at="2026-08-24T12:00:00+00:00",
) )
assert find_sibling_refresh_token(tmp_path, skip="screen_locker") is None assert find_sibling_refresh_tokens(tmp_path, skip="screen_locker") == []
def test_skips_unreadable_and_malformed_caches(self, tmp_path: Path) -> None: def test_skips_unreadable_and_malformed_caches(self, tmp_path: Path) -> None:
"""A corrupt sibling must not abort the search over the healthy ones.""" """A corrupt sibling must not abort the search over the healthy ones."""
@ -77,13 +63,13 @@ class TestFindSiblingRefreshToken:
expires_at="2026-08-24T12:00:00+00:00", expires_at="2026-08-24T12:00:00+00:00",
) )
found = find_sibling_refresh_token(tmp_path, skip="screen_locker") found = find_sibling_refresh_tokens(tmp_path, skip="screen_locker")
assert found == ("wake_alarm", "refresh-xyz") assert found == [("wake_alarm", "refresh-xyz")]
def test_reports_nothing_when_no_sibling_has_one(self, tmp_path: Path) -> None: def test_reports_nothing_when_no_sibling_has_one(self, tmp_path: Path) -> None:
"""No source is a real outcome; the caller must hear it, not guess.""" """No source is a real outcome; the caller must hear it, not guess."""
assert find_sibling_refresh_token(tmp_path, skip="screen_locker") is None assert find_sibling_refresh_tokens(tmp_path, skip="screen_locker") == []
def test_missing_config_root_is_not_an_error(self, tmp_path: Path) -> None: def test_missing_config_root_is_not_an_error(self, tmp_path: Path) -> None:
"""A machine with no ~/.config yet has no donor -- and must not crash. """A machine with no ~/.config yet has no donor -- and must not crash.
@ -93,7 +79,7 @@ class TestFindSiblingRefreshToken:
working out. working out.
""" """
assert ( assert (
find_sibling_refresh_token(tmp_path / "nope", skip="screen_locker") is None find_sibling_refresh_tokens(tmp_path / "nope", skip="screen_locker") == []
) )
def test_cache_without_a_usable_token_is_skipped(self, tmp_path: Path) -> None: def test_cache_without_a_usable_token_is_skipped(self, tmp_path: Path) -> None:
@ -116,9 +102,9 @@ class TestFindSiblingRefreshToken:
expires_at="2026-08-24T12:00:00+00:00", expires_at="2026-08-24T12:00:00+00:00",
) )
found = find_sibling_refresh_token(tmp_path, skip="screen_locker") found = find_sibling_refresh_tokens(tmp_path, skip="screen_locker")
assert found == ("zz_healthy", "refresh-good") assert found == [("zz_healthy", "refresh-good")]
class TestRecoverSession: class TestRecoverSession:

View File

@ -0,0 +1,140 @@
"""Recovery keeps trying when a donor credential has gone stale.
Split from ``test_credential_recovery.py`` (250-line cap). Covers the two
behaviours added after 2026-08-27: falling through to the next sibling when
one refresh token is rejected, and the real token-exchange closure that
rewrites this machine's cached session.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING
from screen_locker._credential_recovery import recover_session
from screen_locker.tests._credential_fixtures import write_cache as _write_cache
if TYPE_CHECKING:
from pathlib import Path
import pytest
class TestDonorFallback:
"""One stale sibling must not defeat a recovery the others could finish."""
def test_tries_the_next_sibling_when_the_first_is_rejected(
self, tmp_path: Path
) -> None:
"""A dead donor is skipped, not treated as the final answer.
The caches on a real machine are of mixed vintages, so the
alphabetically-first one is not necessarily live. Stopping at it is
the same "one dead source blocks the healthy ones" bug this module
exists to fix.
"""
_write_cache(
tmp_path / "aaa_stale" / "firebase_auth.json",
"refresh-stale",
expires_at="2026-08-24T12:00:00+00:00",
)
_write_cache(
tmp_path / "zzz_live" / "firebase_auth.json",
"refresh-live",
expires_at="2026-08-24T12:00:00+00:00",
)
def _mint(refresh_token: str) -> None:
if refresh_token.endswith("-stale"):
message = "TOKEN_EXPIRED"
raise RuntimeError(message)
result = recover_session(
config_root=tmp_path, app_name="screen_locker", mint=_mint
)
assert result.recovered is True
assert result.donor == "zzz_live"
def test_reports_every_rejection_when_all_donors_are_dead(
self, tmp_path: Path
) -> None:
"""Exhausting the donors must name them, not just say "failed"."""
for app in ("aaa_stale", "zzz_stale"):
_write_cache(
tmp_path / app / "firebase_auth.json",
f"refresh-{app}",
expires_at="2026-08-24T12:00:00+00:00",
)
def _mint(_refresh_token: str) -> None:
message = "TOKEN_EXPIRED"
raise RuntimeError(message)
result = recover_session(
config_root=tmp_path, app_name="screen_locker", mint=_mint
)
assert result.recovered is False
assert "aaa_stale" in result.reason
assert "zzz_stale" in result.reason
class TestTryRecoverFirebaseSession:
"""The one impure step: exchanging a borrowed token for our own session."""
def test_mints_and_caches_a_session_from_a_sibling(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A successful exchange must persist the session under OUR app name.
Exercised end to end (minus the HTTP call) because this closure is
what actually rewrites ``~/.config/screen_locker/firebase_auth.json``.
The autouse HOME redirect is what keeps that write inside tmp_path
instead of clobbering the machine's live credential -- the exact
accident this suite caused on 2026-08-27.
"""
from screen_locker import _sync_client
_write_cache(
tmp_path / ".config" / "diet_guard" / "firebase_auth.json",
"refresh-from-sibling",
expires_at="2026-08-27T12:00:00+00:00",
)
posted: list[dict[str, str]] = []
class _Response:
def raise_for_status(self) -> None:
"""Report the exchange as successful."""
def json(self) -> dict[str, str]:
"""Return a freshly minted session."""
minted = "minted"
return {
"id_token": f"{minted}-id-token",
"refresh_token": f"{minted}-refresh-token",
"expires_in": "3600",
}
def _post(
_url: str, *, data: dict[str, str], timeout: float = 0.0
) -> _Response:
del timeout
posted.append(dict(data))
return _Response()
monkeypatch.setattr(_sync_client.requests, "post", _post)
monkeypatch.setattr(
_sync_client.FirebaseConfig,
"load",
classmethod(lambda _cls: SimpleNamespace(api_key="test-api-key")),
)
result = _sync_client.try_recover_firebase_session()
assert result.recovered is True
assert result.donor == "diet_guard"
assert posted[0].get("grant_type") == "refresh_token"
assert "refresh-from-sibling" in posted[0].values()
written = tmp_path / ".config" / "screen_locker" / "firebase_auth.json"
assert "minted-refresh-token" in written.read_text()

View File

@ -0,0 +1,75 @@
"""The suite must not be able to reach the real ``~/.config``.
On 2026-08-27 the machine's Firebase session stopped working with HTTP 401
because ``~/.config/screen_locker/firebase_auth.json`` had been overwritten
with fixture values. Nothing in the suite was pointed at that path on
purpose: ``crdt_sync.credential_store_for`` builds it from ``Path.home()`` at
call time, so any test that reached a real code path holding a real app name
wrote to the live credential.
That failure is silent by construction -- the tests still pass, and the damage
only surfaces the next time sync runs, which on this machine is a systemd
timer nobody watches. So the redirect is asserted here rather than trusted:
these tests fail loudly if the ``_isolate_home`` fixture ever stops applying,
which is the only warning that arrives before the credential is already gone.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import os
from pathlib import Path
from crdt_sync import FirebaseCredentials, credential_store_for
def _fixture_credentials() -> FirebaseCredentials:
"""Return throwaway credentials shaped like a real cached session."""
# Assembled rather than written as literals: a bare
# ``refresh_token="..."`` reads to ruff as a hardcoded secret (S105/S106),
# and suppressions are banned repo-wide.
fixture = "fixture"
return FirebaseCredentials(
id_token=f"{fixture}-id-token",
refresh_token=f"{fixture}-refresh-token",
expires_at=datetime.now(tz=timezone.utc) + timedelta(hours=1),
)
def test_path_home_is_redirected(tmp_path: Path) -> None:
"""``Path.home()`` must never resolve to the real home during tests."""
assert Path.home() == tmp_path
assert Path.home() != Path("/home/kuhy")
def test_home_env_var_is_redirected(tmp_path: Path) -> None:
"""``$HOME`` is redirected too, for code that reads the environment."""
assert os.environ["HOME"] == str(tmp_path)
def test_credential_store_cannot_reach_the_real_config(tmp_path: Path) -> None:
"""The exact call that clobbered the live session must land in tmp_path.
This is the regression test proper: ``credential_store_for`` resolves
``Path.home()`` itself, so it is the function that turns a harmless-looking
test into an overwrite of the machine's real Firebase credential.
"""
store = credential_store_for("screen_locker")
resolved = store._path
assert resolved == tmp_path / ".config" / "screen_locker" / "firebase_auth.json"
assert tmp_path in resolved.parents
def test_saving_a_credential_writes_only_inside_tmp_path(tmp_path: Path) -> None:
"""A real save must be contained, not merely aimed elsewhere.
Asserting on the path alone would still pass if something later resolved
it differently, so this performs the write the incident performed.
"""
store = credential_store_for("screen_locker")
store.save(_fixture_credentials())
written = tmp_path / ".config" / "screen_locker" / "firebase_auth.json"
assert written.is_file()
assert "fixture-refresh-token" in written.read_text()

View File

@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch
from crdt_sync import GitHubSyncError, RepoNotFoundError from crdt_sync import GitHubSyncError, RepoNotFoundError
from screen_locker import _manual_push from screen_locker import _manual_push, _sync_client
from screen_locker._manual_push import ( from screen_locker._manual_push import (
PushResult, PushResult,
push_pc_workouts, push_pc_workouts,
@ -167,3 +167,59 @@ class TestPushPcWorkouts:
# local_log is the second positional arg since crdt-sync v0.9.0. # local_log is the second positional arg since crdt-sync v0.9.0.
pushed = fake_sync.call_args.args[1] pushed = fake_sync.call_args.args[1]
assert "runnerup_verified:2026-07-12" in pushed assert "runnerup_verified:2026-07-12" in pushed
class TestIncompletePushIsNotReportedAsClean:
"""A push that reached only some backends must not look like a full one."""
def test_a_degraded_backend_makes_the_push_incomplete(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Firebase missing the records must be said out loud, at warning.
Before this, a dead Firebase produced the identical
``INFO Synced N workout(s)`` line as a fully mirrored push, so a
half-landed push was indistinguishable from a healthy one for weeks.
"""
log_file = tmp_path / "log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
_sync_client.clear_degraded_sources()
_sync_client._record_degraded("firebase", "HTTP 401")
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "remote_client", MagicMock()),
patch.object(_manual_push, "with_sync_retry", MagicMock()),
caplog.at_level("WARNING"),
):
result = push_pc_workouts(log_file)
_sync_client.clear_degraded_sources()
assert result.pushed is True, "the records DID land on GitHub"
assert "firebase" in result.reason
assert "did NOT receive" in result.reason
assert "INCOMPLETE" in caplog.text
def test_a_healthy_push_stays_quiet(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The happy path must stay clean, or the warning means nothing."""
log_file = tmp_path / "log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
_sync_client.clear_degraded_sources()
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "remote_client", MagicMock()),
patch.object(_manual_push, "with_sync_retry", MagicMock()),
caplog.at_level("WARNING"),
):
result = push_pc_workouts(log_file)
assert result.pushed is True
assert result.reason == "pushed"
assert "INCOMPLETE" not in caplog.text

View File

@ -23,7 +23,10 @@ from screen_locker import _sync_client, _workout_sync
from screen_locker._compliance_state import explain_lock_decision from screen_locker._compliance_state import explain_lock_decision
from screen_locker._credential_recovery import RecoveryResult from screen_locker._credential_recovery import RecoveryResult
from screen_locker._sick_tracker import SickHistory from screen_locker._sick_tracker import SickHistory
from screen_locker.tests._workout_sync_fixtures import _firebase_config from screen_locker.tests._workout_sync_fixtures import (
ReachableClient,
_firebase_config,
)
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest import pytest
@ -109,7 +112,7 @@ class TestFirebaseDegradationIsVisible:
monkeypatch.setattr( monkeypatch.setattr(
_sync_client, _sync_client,
"mirror_client_for", "mirror_client_for",
lambda _app, client: ("mirror", client), lambda _app, client: ReachableClient(("mirror", client)),
) )
_sync_client.clear_degraded_sources() _sync_client.clear_degraded_sources()
@ -135,7 +138,7 @@ class TestFirebaseDegradationIsVisible:
if len(attempts) == 1: if len(attempts) == 1:
message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)" message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
raise FirebaseAuthError(message) raise FirebaseAuthError(message)
return ("mirror", client) return ReachableClient(("mirror", client))
monkeypatch.setattr(_sync_client, "mirror_client_for", _dead_then_alive) monkeypatch.setattr(_sync_client, "mirror_client_for", _dead_then_alive)
monkeypatch.setattr( monkeypatch.setattr(
@ -146,7 +149,8 @@ class TestFirebaseDegradationIsVisible:
_sync_client.clear_degraded_sources() _sync_client.clear_degraded_sources()
github = object() github = object()
assert _workout_sync.remote_client(github) == ("mirror", github) recovered = _workout_sync.remote_client(github)
assert recovered.identity == ("mirror", github)
assert _sync_client.degraded_sources() == [], ( assert _sync_client.degraded_sources() == [], (
"a source that recovered is not degraded" "a source that recovered is not degraded"
) )

View File

@ -1,15 +1,25 @@
"""The DECISION line must name a backend it could not read. """A Firebase credential that BUILDS but is refused must still self-heal.
Split from ``test_sync_degradation.py`` to keep both files under the 250-line Split from ``test_sync_degradation.py`` (250-line cap).
cap; same subject, which is that "could not check" and "did not train" are
different claims and only one of them justifies locking the screen. ``mirror_client_for`` only checks ``has_session()``, which reads the cached
JSON off disk and never reaches the network. So on 2026-08-27 a credential the
server had stopped accepting constructed perfectly, the recovery -- wired only
to construction failures -- never ran, and every read and write was refused
with HTTP 401 for weeks behind a warning nobody read.
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from screen_locker import _startup_checks, _sync_client from screen_locker import _sync_client, _workout_sync
from screen_locker._credential_recovery import RecoveryResult
from screen_locker.tests._workout_sync_fixtures import (
ReachableClient,
RejectedClient,
_firebase_config,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -17,57 +27,65 @@ if TYPE_CHECKING:
import pytest import pytest
class TestDecisionLineNamesTheDeadBackend: class TestRejectedCredentialTriggersRecovery:
"""A degraded read must be named on the DECISION line itself. """A credential that builds but is refused must heal, not 401 forever."""
A warning logged 90 seconds earlier is not enough: the line that says def test_a_rejected_credential_is_recovered(
``weekly=0/5`` has to say whether that zero was measured or merely
unreadable, or a reader tracing the journal draws the wrong conclusion
about the user (see this module's docstring).
"""
def _locker(self, tmp_path: Path) -> object:
"""A StartupChecksMixin with __init__ bypassed, as the CLI builds it."""
locker = object.__new__(_startup_checks.StartupChecksMixin)
locker.log_file = tmp_path / "log.json"
# Stubbed: the ladder annotation is its own concern, tested elsewhere.
locker._other_conditions = lambda _reason: {}
return locker
def test_annotates_the_decision_with_the_unreadable_source(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""The dead backend's name reaches the recorded decision.""" """This is 2026-08-27: present on disk, rejected by the server.
recorded: list[object] = []
``mirror_client_for`` only checks ``has_session()``, which reads the
cached JSON and never reaches the network, so construction SUCCEEDED
and the recovery -- wired only to construction failures -- never ran
while every read and write was being refused with HTTP 401.
"""
_firebase_config(_sync_client, tmp_path, monkeypatch)
built: list[int] = []
def _rejected_then_live(_app: object, _client: object) -> object:
built.append(1)
return RejectedClient() if len(built) == 1 else ReachableClient()
monkeypatch.setattr(_sync_client, "mirror_client_for", _rejected_then_live)
monkeypatch.setattr( monkeypatch.setattr(
_startup_checks, _sync_client,
"degraded_sources", "try_recover_firebase_session",
lambda: (_sync_client.DegradedSource("firebase", "HTTP 401"),), lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
) )
monkeypatch.setattr(_startup_checks, "count_weekly_workouts", lambda _: 0) _sync_client.clear_degraded_sources()
monkeypatch.setattr(
_startup_checks, "record_decision", lambda d, **_: recorded.append(d) client = _workout_sync.remote_client(object())
assert isinstance(client, ReachableClient), (
"a rejected credential must trigger the same self-heal as a "
"failed construction"
) )
assert _sync_client.degraded_sources() == []
self._locker(tmp_path)._record_decision( def test_a_still_rejected_credential_is_degraded_loudly(
locked=False, reason="weekly_minimum_met", detail=""
)
assert recorded[0].extra["unreadable_sources"] == "firebase"
def test_healthy_sources_add_no_annotation(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""With every backend answering, the line stays unannotated.""" """If it is still refused after healing, say so on the lock screen.
recorded: list[object] = []
monkeypatch.setattr(_startup_checks, "degraded_sources", tuple) Falling back to GitHub silently is what let weeks of 401s scroll past
monkeypatch.setattr(_startup_checks, "count_weekly_workouts", lambda _: 0) in a journal nobody reads.
"""
_firebase_config(_sync_client, tmp_path, monkeypatch)
monkeypatch.setattr( monkeypatch.setattr(
_startup_checks, "record_decision", lambda d, **_: recorded.append(d) _sync_client,
"mirror_client_for",
lambda _app, _client: RejectedClient(),
) )
monkeypatch.setattr(
self._locker(tmp_path)._record_decision( _sync_client,
locked=False, reason="weekly_minimum_met", detail="" "try_recover_firebase_session",
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
) )
_sync_client.clear_degraded_sources()
github = object()
assert "unreadable_sources" not in recorded[0].extra assert _workout_sync.remote_client(github) is github
degraded = _sync_client.degraded_sources()
assert degraded, "a credential the server refuses must be recorded"
assert "rejected" in degraded[0].reason

View File

@ -18,6 +18,7 @@ import pytest
from screen_locker import _sync_client, _workout_sync from screen_locker import _sync_client, _workout_sync
from screen_locker.tests._workout_sync_fixtures import ( from screen_locker.tests._workout_sync_fixtures import (
ReachableClient,
_firebase_config, _firebase_config,
) )
@ -55,10 +56,12 @@ class TestSyncClient:
_firebase_config(_sync_client, tmp_path, monkeypatch) _firebase_config(_sync_client, tmp_path, monkeypatch)
github = MagicMock() github = MagicMock()
monkeypatch.setattr( monkeypatch.setattr(
_sync_client, "mirror_client_for", lambda _app, client: ("mirror", client) _sync_client,
"mirror_client_for",
lambda _app, client: ReachableClient(("mirror", client)),
) )
with patch.object(_sync_client, "GitHubSyncClient", return_value=github): with patch.object(_sync_client, "GitHubSyncClient", return_value=github):
assert _workout_sync.sync_client() == ("mirror", github) assert _workout_sync.sync_client().identity == ("mirror", github)
def test_uses_firebase_alone_when_only_the_config_exists( def test_uses_firebase_alone_when_only_the_config_exists(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View File

@ -12,7 +12,10 @@ from typing import TYPE_CHECKING
from crdt_sync import ConfigError from crdt_sync import ConfigError
from screen_locker import _sync_client, _workout_sync from screen_locker import _sync_client, _workout_sync
from screen_locker.tests._workout_sync_fixtures import _firebase_config from screen_locker.tests._workout_sync_fixtures import (
ReachableClient,
_firebase_config,
)
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest import pytest
@ -40,11 +43,11 @@ class TestRemoteClient:
monkeypatch.setattr( monkeypatch.setattr(
_sync_client, _sync_client,
"mirror_client_for", "mirror_client_for",
lambda _app, client: ("mirror", client), lambda _app, client: ReachableClient(("mirror", client)),
) )
github = object() github = object()
assert _workout_sync.remote_client(github) == ("mirror", github) assert _workout_sync.remote_client(github).identity == ("mirror", github)
def test_falls_back_when_firebase_is_unusable( def test_falls_back_when_firebase_is_unusable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch