diff --git a/screen_locker/tests/test_credential_recovery.py b/screen_locker/tests/test_credential_recovery.py index 803ff93..9a66bb0 100644 --- a/screen_locker/tests/test_credential_recovery.py +++ b/screen_locker/tests/test_credential_recovery.py @@ -85,6 +85,41 @@ class TestFindSiblingRefreshToken: """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.""" diff --git a/screen_locker/tests/test_source_health.py b/screen_locker/tests/test_source_health.py index cf9cc9b..f799f21 100644 --- a/screen_locker/tests/test_source_health.py +++ b/screen_locker/tests/test_source_health.py @@ -9,8 +9,10 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone +from screen_locker import _sync_client from screen_locker._source_health import ( SourceFinding, + collect_source_findings, describe_staleness, explain_findings, ) @@ -18,6 +20,25 @@ from screen_locker._source_health import ( _NOW = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc) +class TestCollectSourceFindings: + """The healthy machine must produce no findings at all. + + An always-on warning is one nobody reads. If a fresh, working sync still + printed "this may be wrong", the panel added for 2026-08-24 would become + background noise and stop carrying information. + """ + + def test_healthy_and_fresh_reports_nothing(self) -> None: + """No dead backend and recent data means silence.""" + _sync_client.clear_degraded_sources() + + findings = collect_source_findings( + newest_synced=_NOW - timedelta(hours=3), now=_NOW + ) + + assert findings == [] + + class TestDescribeStaleness: """Turning "answered fine, but the data is old" into a stated fault.""" diff --git a/screen_locker/tests/test_sync_degradation.py b/screen_locker/tests/test_sync_degradation.py index d8cc88e..62ddaab 100644 --- a/screen_locker/tests/test_sync_degradation.py +++ b/screen_locker/tests/test_sync_degradation.py @@ -21,6 +21,7 @@ from crdt_sync import FirebaseAuthError from screen_locker import _sync_client, _workout_sync from screen_locker._compliance_state import explain_lock_decision +from screen_locker._credential_recovery import RecoveryResult from screen_locker._sick_tracker import SickHistory from screen_locker.tests._workout_sync_fixtures import _firebase_config @@ -116,6 +117,101 @@ class TestFirebaseDegradationIsVisible: assert _sync_client.degraded_sources() == [] + def test_successful_recovery_returns_a_working_client( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Healing must actually hand back Firebase, not a degraded fallback. + + This is the path that ran for real on 2026-08-24: the cached + credential was dead, a sibling app's refresh token rebuilt the + session, and the retry succeeded. Nothing may be recorded as + degraded -- the source ended up readable. + """ + _firebase_config(_sync_client, tmp_path, monkeypatch) + attempts: list[int] = [] + + def _dead_then_alive(_app: object, client: object) -> object: + attempts.append(1) + if len(attempts) == 1: + message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)" + raise FirebaseAuthError(message) + return ("mirror", client) + + monkeypatch.setattr(_sync_client, "mirror_client_for", _dead_then_alive) + monkeypatch.setattr( + _sync_client, + "try_recover_firebase_session", + lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"), + ) + _sync_client.clear_degraded_sources() + github = object() + + assert _workout_sync.remote_client(github) == ("mirror", github) + assert _sync_client.degraded_sources() == [], ( + "a source that recovered is not degraded" + ) + + def test_recovery_that_still_cannot_sign_in_is_degraded( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A token that mints but still fails must degrade, not pretend. + + Recovery reporting success is not proof the backend is readable, so + the retry is what decides. If it raises too, this is the same lockout + as before and must be recorded -- silently returning the GitHub + mirror is exactly how 2026-08-24 stayed invisible. + """ + _firebase_config(_sync_client, tmp_path, monkeypatch) + + def _always_dead(*_args: object, **_kwargs: object) -> None: + msg = "still 400 after refresh" + raise FirebaseAuthError(msg) + + monkeypatch.setattr(_sync_client, "mirror_client_for", _always_dead) + monkeypatch.setattr( + _sync_client, + "try_recover_firebase_session", + lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"), + ) + _sync_client.clear_degraded_sources() + github = object() + + assert _workout_sync.remote_client(github) is github + degraded = _sync_client.degraded_sources() + assert degraded, "a retry that still fails must be recorded" + assert "still 400 after refresh" in degraded[0].reason + + def test_failed_recovery_records_both_the_cause_and_the_attempt( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When healing fails, say why it failed as well as why it was needed. + + This is 2026-08-24 on a machine with no sibling to borrow from. The + recorded reason has to carry both halves, because "Firebase is dead" + and "and I could not fix it myself" prompt different actions. + """ + _firebase_config(_sync_client, tmp_path, monkeypatch) + + def _boom(*_args: object, **_kwargs: object) -> None: + msg = "HTTP 400 (INVALID_LOGIN_CREDENTIALS)" + raise FirebaseAuthError(msg) + + monkeypatch.setattr(_sync_client, "mirror_client_for", _boom) + monkeypatch.setattr( + _sync_client, + "try_recover_firebase_session", + lambda: RecoveryResult( + recovered=False, reason="no sibling app has a cached refresh token" + ), + ) + _sync_client.clear_degraded_sources() + github = object() + + assert _workout_sync.remote_client(github) is github + reason = _sync_client.degraded_sources()[0].reason + assert "INVALID_LOGIN_CREDENTIALS" in reason + assert "no sibling app" in reason + def test_unconfigured_machine_is_not_degraded( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/screen_locker/tests/test_ui_and_timers.py b/screen_locker/tests/test_ui_and_timers.py index c6a7849..96eb5cb 100644 --- a/screen_locker/tests/test_ui_and_timers.py +++ b/screen_locker/tests/test_ui_and_timers.py @@ -5,11 +5,14 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock +from screen_locker import _sync_client, _ui_flows from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path + import pytest + class TestUITransitions: """Tests for UI state transitions.""" @@ -192,3 +195,33 @@ class TestShowRetryAndSick: locker.clear_container.assert_called_once() mock_tk.Label.assert_called() mock_tk.Button.assert_called() + + def test_healthy_sources_add_no_diagnosis_block( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """With every source healthy the screen must not hedge. + + The "why this may be wrong" panel exists for 2026-08-24, when the + machine genuinely could not see the workout. When it *can* see, an + unconditional caveat would teach the user to ignore the one message + that matters. + """ + _sync_client.clear_degraded_sources() + # A healthy machine has a readable backend AND a non-empty log, which + # this tmp_path fixture cannot provide -- an empty sync is itself a + # finding. Stub the collector so the branch under test is "nothing to + # report", not "no workouts synced". + monkeypatch.setattr(_ui_flows, "collect_source_findings", list) + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "clear_container", MagicMock()) + + locker._show_retry_and_sick("Test message") + + shown = " ".join( + str(call.kwargs.get("text", "")) for call in mock_tk.Label.call_args_list + ) + assert "may be wrong" not in shown