screen-locker/screen_locker/tests/test_credential_recovery.py
Krzysztof kuhy Rudnicki 16ada6e5a2 Tell "could not check" apart from "no workout", and stop double-granting bonuses
A real 1h57m workout on 2026-08-24 synced to Firebase and the locker still
locked the screen. The trigger was auth: screen_locker was the only app with no
cached firebase_auth.json, so it fell back to a stale shared password and got
HTTP 400 every run. The defect was what it did next — an unreadable primary
source was reported as "no workout" rather than "could not check", which is the
same conflation that took 2026-06-12.

Recover the session by borrowing a live refresh token from a sibling app, track
degraded sources as state instead of a log line, and say so on the lock screen:
name the backend, the error, and how stale the data is, so a sync fault reads
as a sync fault.

Also fix two bugs found while finishing the recovery:

restore_and_bonus.py banked `existing + bonus` with no idempotence guard while
finish_2026_08_24.sh advertised "safe to re-run" — a second run silently
granted the compensation hours again. Guard on a durable
`shutdown_bonus_granted_for` list; the bonus map alone cannot answer the
question, since a week's total mixes earned streak hours with corrections.

_extra_benefits._save_state rebuilt state from four hardcoded keys, so the next
weekly rollover would have dropped that guard and re-armed the double-grant.
Merge onto loaded state instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
2026-08-24 13:07:20 +02:00

144 lines
4.9 KiB
Python

"""A dead Firebase credential must heal itself, not wait for a human.
On 2026-08-24 the PC could not read Firebase because ``screen_locker`` had no
cached session and the shared password no longer authenticated. Every sibling
app on the same machine -- ``diet_guard``, ``wake_alarm``, ``todo`` -- held a
live refresh token for the *same* account and project the whole time. The
credential was on disk, three directories away, and nothing looked for it.
Recovery is therefore local and needs no human: borrow a sibling's refresh
token, mint a session, and cache it under our own app name.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from screen_locker._credential_recovery import (
find_sibling_refresh_token,
recover_session,
)
if TYPE_CHECKING:
from pathlib import Path
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:
"""Locating a live credential already present on this machine."""
def test_finds_a_sibling_apps_token(self, tmp_path: Path) -> None:
"""Any sibling app's cache is a valid source -- one account, one project."""
_write_cache(
tmp_path / "diet_guard" / "firebase_auth.json",
"refresh-abc",
expires_at="2026-08-24T12:00:00+00:00",
)
found = find_sibling_refresh_token(tmp_path, skip="screen_locker")
assert found == ("diet_guard", "refresh-abc")
def test_ignores_our_own_stale_cache(self, tmp_path: Path) -> None:
"""Our own dead cache is the thing being replaced, never the source."""
_write_cache(
tmp_path / "screen_locker" / "firebase_auth.json",
"refresh-ours",
expires_at="2026-08-24T12:00:00+00:00",
)
assert find_sibling_refresh_token(tmp_path, skip="screen_locker") is 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."""
bad = tmp_path / "broken_app" / "firebase_auth.json"
bad.parent.mkdir(parents=True)
bad.write_text("{not json")
_write_cache(
tmp_path / "wake_alarm" / "firebase_auth.json",
"refresh-xyz",
expires_at="2026-08-24T12:00:00+00:00",
)
found = find_sibling_refresh_token(tmp_path, skip="screen_locker")
assert found == ("wake_alarm", "refresh-xyz")
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."""
assert find_sibling_refresh_token(tmp_path, skip="screen_locker") is None
class TestRecoverSession:
"""Turning a borrowed refresh token into our own cached session."""
def test_recovers_and_reports_the_donor(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A successful recovery must say where the credential came from."""
_write_cache(
tmp_path / "diet_guard" / "firebase_auth.json",
"refresh-abc",
expires_at="2026-08-24T12:00:00+00:00",
)
minted: list[str] = []
def _mint(refresh_token: str) -> None:
minted.append(refresh_token)
result = recover_session(
config_root=tmp_path, app_name="screen_locker", mint=_mint
)
assert result.recovered is True
assert result.donor == "diet_guard"
assert minted == ["refresh-abc"]
def test_failure_explains_itself(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A failed recovery must carry a reason a human can act on."""
def _mint(_refresh_token: str) -> None:
message = "TOKEN_EXPIRED"
raise RuntimeError(message)
_write_cache(
tmp_path / "diet_guard" / "firebase_auth.json",
"refresh-abc",
expires_at="2026-08-24T12:00:00+00:00",
)
result = recover_session(
config_root=tmp_path, app_name="screen_locker", mint=_mint
)
assert result.recovered is False
assert "TOKEN_EXPIRED" in result.reason
def test_no_donor_is_reported_not_swallowed(self, tmp_path: Path) -> None:
""" "Nothing to borrow" must be a stated reason, never a silent False."""
result = recover_session(
config_root=tmp_path, app_name="screen_locker", mint=lambda _t: None
)
assert result.recovered is False
assert "no sibling" in result.reason.lower()