screen-locker/screen_locker/tests/test_credential_recovery.py
Krzysztof kuhy Rudnicki 075f7bae8c
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Cover the recovery branches, restoring the 100% gate
The previous session reported the new code green, but measured it with
--no-cov (step 1 of finish_2026_08_24.sh passes that flag), so seven branches
in the recovery path had never been exercised. Coverage was 99.78%.

Most of the gap was the auto-recovery itself: whether healing succeeded,
whether the retry after healing succeeded, and what gets recorded when it
does not. That is the code which ran in production for the first time today,
and none of its outcomes were pinned.

Also covers the donor search on a machine with no ~/.config, siblings whose
cache is readable but holds no usable token, and the two healthy paths that
must stay silent — an always-on "this may be wrong" panel is one nobody reads.

Writing the UI test surfaced that collect_source_findings is never empty for
a fresh tmp_path, because an empty sync is itself a finding; the stub is
there so the branch under test is "nothing to report".

1115 passed, coverage back to 100%.

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

179 lines
6.4 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
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.
Recovery runs unattended inside a lock decision, so an exception here
would take the screen for a reason that has nothing to do with
working out.
"""
assert (
find_sibling_refresh_token(tmp_path / "nope", skip="screen_locker") is None
)
def test_cache_without_a_usable_token_is_skipped(self, tmp_path: Path) -> None:
"""Readable JSON is not the same as a usable credential.
A sibling mid-logout leaves a well-formed cache whose refresh_token is
missing or empty. Returning that would mint nothing and report success.
"""
for app, payload in (
("no_token_app", {"expires_at": "2026-08-24T12:00:00+00:00"}),
("empty_token_app", {"refresh_token": ""}),
("wrong_type_app", {"refresh_token": 12345}),
):
cache = tmp_path / app / "firebase_auth.json"
cache.parent.mkdir(parents=True)
cache.write_text(json.dumps(payload))
_write_cache(
tmp_path / "zz_healthy" / "firebase_auth.json",
"refresh-good",
expires_at="2026-08-24T12:00:00+00:00",
)
found = find_sibling_refresh_token(tmp_path, skip="screen_locker")
assert found == ("zz_healthy", "refresh-good")
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()