diff --git a/crdt-sync/crdt_sync/tests/test_tool_link_google_main.py b/crdt-sync/crdt_sync/tests/test_tool_link_google_main.py index a65069c..bacabaa 100644 --- a/crdt-sync/crdt_sync/tests/test_tool_link_google_main.py +++ b/crdt-sync/crdt_sync/tests/test_tool_link_google_main.py @@ -19,10 +19,9 @@ import pytest from crdt_sync._config import ConfigError from crdt_sync._firebase_auth import FirebaseAuthError +from crdt_sync.tests.test_tool_link_google import _CONFIG, _UID, _google_token from tool import link_google as lg -from .test_tool_link_google import _CONFIG, _UID, _google_token - if TYPE_CHECKING: from pathlib import Path @@ -39,7 +38,7 @@ def _args( def test_a_token_is_read_from_the_command_line() -> None: """The inline form.""" - assert lg._read_token(_args(token="the-token")) == "the-token" + assert lg._read_token(_args("the-token")) == "the-token" def test_a_token_is_read_from_a_file(tmp_path: Path) -> None: @@ -82,7 +81,7 @@ def test_an_empty_token_is_rejected(tmp_path: Path) -> None: def test_an_empty_inline_token_is_rejected() -> None: """Same check, the other input route.""" with pytest.raises(lg.LinkError, match="is empty"): - lg._read_token(_args(token="")) + lg._read_token(_args("")) def test_main_links_and_reports_the_unchanged_uid( diff --git a/crdt-sync/crdt_sync/tests/test_tool_seed_session.py b/crdt-sync/crdt_sync/tests/test_tool_seed_session.py new file mode 100644 index 0000000..980d5d3 --- /dev/null +++ b/crdt-sync/crdt_sync/tests/test_tool_seed_session.py @@ -0,0 +1,226 @@ +"""Tests for ``seed_apps`` in ``tool/seed_session``. + +``main()`` is covered in :mod:`test_tool_seed_session_main`, split to stay +under the 250-line cap. + +The reason this script exists is that credential files were present and the +tokens inside them were dead, so "the file was written" proves nothing. Two +structural decisions follow from that and are what the tests pin: every app +gets its own exchange, and verification happens in a *second pass* after all +sessions exist -- because minting a new session for the same uid can +invalidate an earlier app's refresh token, which a check-as-you-go loop would +never see. +""" + +from __future__ import annotations + +import logging +from unittest.mock import MagicMock, patch + +import pytest + +from crdt_sync import FirebaseConfig +from crdt_sync._firebase_auth import FirebaseAuthError +from crdt_sync._remote import RemoteSyncError +from tool import seed_session as ss + +_UID = "the-uid-pinned-in-the-rules" +_CONFIG = FirebaseConfig( + api_key="AIzaSyAthisIsTheWebApiKeyFormat", + database_url="https://project-default-rtdb.firebasedatabase.app", + project_id="the-project", + uid=_UID, + email="sync@example.com", +) +_ID_VALUE = "the-google-id-token" +_APPS = ("diet_guard", "screen_locker") + + +@pytest.fixture +def seeding() -> tuple[MagicMock, MagicMock, MagicMock]: + """Patch the auth provider, credential store and sync client.""" + provider = MagicMock() + provider.sign_in_with_google.return_value = "person@gmail.com" + + with ( + patch.object(ss, "FirebaseTokenProvider", return_value=provider) as factory, + patch.object(ss, "credential_store_for") as store_for, + patch.object(ss, "FirebaseSyncClient") as client_factory, + ): + yield factory, store_for, client_factory + + +def test_every_named_app_is_seeded_and_returned(seeding: tuple) -> None: + """The happy path returns the apps that verified, in order.""" + assert ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) == list(_APPS) + + +def test_each_app_gets_its_own_credential_store(seeding: tuple) -> None: + """A single session copied across apps would have them all refreshing + the same rotated token and invalidating each other.""" + _, store_for, _ = seeding + + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + seeded_names = [call.args[0] for call in store_for.call_args_list] + assert set(seeded_names) == set(_APPS) + + +def test_each_app_gets_its_own_exchange(seeding: tuple) -> None: + """One sign-in per app, not one shared session.""" + factory, _, _ = seeding + provider = factory.return_value + + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + assert provider.sign_in_with_google.call_count == len(_APPS) + + +def test_the_expected_uid_is_pinned_on_every_exchange(seeding: tuple) -> None: + """A wrong account must be refused before anything is written.""" + factory, _, _ = seeding + provider = factory.return_value + + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + for call in provider.sign_in_with_google.call_args_list: + assert call.args[0] == _ID_VALUE + assert call.kwargs["expected_uid"] == _UID + + +def test_a_rejected_token_stops_before_any_app_is_verified(seeding: tuple) -> None: + """Raised on the first failure, so a wrong account reaches no app.""" + factory, _, client_factory = seeding + factory.return_value.sign_in_with_google.side_effect = FirebaseAuthError("nope") + + with pytest.raises(FirebaseAuthError): + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + client_factory.assert_not_called() + + +def test_verification_happens_after_every_session_exists(seeding: tuple) -> None: + """The structural point of the two-pass design. + + Checking each app as it is written would pass an app that a later + exchange then invalidates -- precisely the failure being guarded against. + """ + factory, _, client_factory = seeding + order: list[str] = [] + factory.return_value.sign_in_with_google.side_effect = lambda *a, **k: ( + order.append("seed") or "person@gmail.com" + ) + client_factory.return_value.list_directory.side_effect = lambda _p: order.append( + "verify" + ) + + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + assert order == ["seed", "seed", "verify", "verify"] + + +def test_each_app_is_verified_with_a_real_authenticated_read( + seeding: tuple, +) -> None: + """A written credential file is not evidence of working sync.""" + _, _, client_factory = seeding + + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + client = client_factory.return_value + assert client.list_directory.call_count == len(_APPS) + for call in client.list_directory.call_args_list: + # The database root, which is where the rules grant .read. + assert call.args == ("",) + + +def test_the_client_is_built_against_the_configured_database( + seeding: tuple, +) -> None: + """Verifying against the wrong database would prove nothing.""" + _, _, client_factory = seeding + + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + assert client_factory.call_args.args[0] == _CONFIG.database_url + + +@pytest.mark.parametrize( + "failure", + [RemoteSyncError("rules denied"), FirebaseAuthError("token dead")], +) +def test_a_session_that_cannot_read_is_a_seed_error( + seeding: tuple, + failure: Exception, +) -> None: + """Both failure shapes are caught, and neither reads as success. + + list_directory is used rather than can_access_remote() precisely so these + stay distinguishable in the message. + """ + _, _, client_factory = seeding + client_factory.return_value.list_directory.side_effect = failure + + with pytest.raises(ss.SeedError, match="cannot read the database"): + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + +def test_a_seed_error_names_the_failing_app(seeding: tuple) -> None: + """Which app broke is the first thing needed to fix it.""" + _, _, client_factory = seeding + client_factory.return_value.list_directory.side_effect = RemoteSyncError("denied") + + with pytest.raises(ss.SeedError, match="diet_guard was seeded but cannot read"): + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + +def test_a_seed_error_carries_the_apps_already_verified(seeding: tuple) -> None: + """So a partial run reports what is left instead of being reconstructed.""" + _, _, client_factory = seeding + client_factory.return_value.list_directory.side_effect = [ + None, + RemoteSyncError("denied"), + ] + + with pytest.raises(ss.SeedError) as raised: + ss.seed_apps(_CONFIG, _ID_VALUE, _APPS) + + assert raised.value.done == ("diet_guard",) + + +def test_a_seed_error_defaults_to_no_completed_apps() -> None: + """Constructed without a done tuple, nothing is claimed as working.""" + assert ss.SeedError("something broke").done == () + + +def test_seeding_no_apps_is_a_no_op(seeding: tuple) -> None: + """An empty selection must not report a spurious success.""" + _, _, client_factory = seeding + + assert ss.seed_apps(_CONFIG, _ID_VALUE, ()) == [] + client_factory.assert_not_called() + + +def test_the_account_email_is_reported_per_app( + seeding: tuple, + caplog: pytest.LogCaptureFixture, +) -> None: + """The operator's check that the right Google account was used.""" + with caplog.at_level(logging.INFO, logger="seed_session"): + ss.seed_apps(_CONFIG, _ID_VALUE, ("diet_guard",)) + + assert "diet_guard seeded as person@gmail.com" in caplog.text + + +def test_the_config_email_is_used_when_google_returns_none( + seeding: tuple, + caplog: pytest.LogCaptureFixture, +) -> None: + """Not every exchange echoes an email; the line must still be useful.""" + factory, _, _ = seeding + factory.return_value.sign_in_with_google.return_value = None + + with caplog.at_level(logging.INFO, logger="seed_session"): + ss.seed_apps(_CONFIG, _ID_VALUE, ("diet_guard",)) + + assert f"diet_guard seeded as {_CONFIG.email}" in caplog.text diff --git a/crdt-sync/crdt_sync/tests/test_tool_seed_session_main.py b/crdt-sync/crdt_sync/tests/test_tool_seed_session_main.py new file mode 100644 index 0000000..3d41f2c --- /dev/null +++ b/crdt-sync/crdt_sync/tests/test_tool_seed_session_main.py @@ -0,0 +1,220 @@ +"""Tests for ``main()`` in ``tool/seed_session``. + +Split from :mod:`test_tool_seed_session` to stay under the 250-line cap. + +The behaviour worth pinning here is what happens when a run dies partway. It +leaves some apps working and some not, and saying exactly which is the +difference between rerunning the ones that need it and a hunt through +``~/.config`` for what actually happened -- so the rerun line is asserted to +list the remaining apps and only those. +""" + +from __future__ import annotations + +import logging +from unittest.mock import patch + +import pytest + +from crdt_sync._config import ConfigError +from crdt_sync._firebase_auth import FirebaseAuthError +from crdt_sync.tests.test_tool_seed_session import _CONFIG, _ID_VALUE +from tool import seed_session as ss +from tool.google_id_token import TokenError + +_CLIENT_ID = "the-web-client-id" +_CLIENT_CREDENTIAL = "the-web-client-secret" +_ARGV = ["--client-id", _CLIENT_ID, "--client-secret", _CLIENT_CREDENTIAL] + + +def test_main_seeds_every_default_app(caplog: pytest.LogCaptureFixture) -> None: + """With no --app, all of DEFAULT_APPS are seeded.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE), + patch.object(ss, "seed_apps", return_value=list(ss.DEFAULT_APPS)) as seeded, + caplog.at_level(logging.INFO, logger="seed_session"), + ): + code = ss.main(_ARGV) + + assert code == 0 + assert seeded.call_args.args[2] == ss.DEFAULT_APPS + assert f"{len(ss.DEFAULT_APPS)} desktop session(s)" in caplog.text + + +def test_todo_is_among_the_default_apps() -> None: + """It grew a desktop wrapper with its own Firebase REST client. + + Its credential cache needs seeding exactly like the Python daemons, which + is why it is no longer grouped with the phone-only Flutter apps. + """ + assert "todo" in ss.DEFAULT_APPS + + +def test_a_single_app_can_be_selected() -> None: + """--app narrows the run, which is what a rerun after a failure uses.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE), + patch.object(ss, "seed_apps", return_value=["diet_guard"]) as seeded, + ): + code = ss.main([*_ARGV, "--app", "diet_guard"]) + + assert code == 0 + assert seeded.call_args.args[2] == ("diet_guard",) + + +def test_the_app_flag_is_repeatable() -> None: + """Rerunning two failed apps should not need two invocations.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE), + patch.object(ss, "seed_apps", return_value=["diet_guard", "todo"]) as seeded, + ): + ss.main([*_ARGV, "--app", "diet_guard", "--app", "todo"]) + + assert seeded.call_args.args[2] == ("diet_guard", "todo") + + +def test_an_unknown_app_is_refused_by_argparse() -> None: + """The constrained choices exist because of a real failure. + + A typo like "diet-guard" would otherwise create ~/.config/diet-guard/, + verify against it happily, and report PASS while the real diet_guard + stayed dead. + """ + with pytest.raises(SystemExit): + ss.main([*_ARGV, "--app", "diet-guard"]) + + +def test_the_consent_flow_receives_the_client_and_port() -> None: + """A Web client needs the registered port verbatim, not a free one.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE) as fetch, + patch.object(ss, "seed_apps", return_value=[]), + ): + ss.main([*_ARGV, "--port", "9999", "--no-browser"]) + + assert fetch.call_args.args == (_CLIENT_ID, _CLIENT_CREDENTIAL) + assert fetch.call_args.kwargs == {"open_browser": False, "port": 9999} + + +def test_the_default_port_is_the_registered_one() -> None: + """8765 is what is registered on the Web client as http://localhost:8765.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE) as fetch, + patch.object(ss, "seed_apps", return_value=[]), + ): + ss.main(_ARGV) + + assert fetch.call_args.kwargs["port"] == 8765 + + +def test_the_expected_uid_is_announced_before_the_flow( + caplog: pytest.LogCaptureFixture, +) -> None: + """So the wrong Google account can be spotted at the picker, not after.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE), + patch.object(ss, "seed_apps", return_value=[]), + caplog.at_level(logging.INFO, logger="seed_session"), + ): + ss.main(_ARGV) + + assert _CONFIG.uid in caplog.text + assert _CONFIG.email in caplog.text + + +@pytest.mark.parametrize( + "failure", + [ + ConfigError("no config"), + FirebaseAuthError("wrong uid"), + TokenError("consent refused"), + ], +) +def test_the_named_failures_return_1( + failure: Exception, + caplog: pytest.LogCaptureFixture, +) -> None: + """Each is reported, not raised as a traceback.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", side_effect=failure), + caplog.at_level(logging.INFO, logger="seed_session"), + ): + code = ss.main(_ARGV) + + assert code == 1 + assert "FAIL" in caplog.text + + +def test_an_unexpected_error_is_not_swallowed() -> None: + """The reason the handler names its four types instead of Exception.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", side_effect=RuntimeError("a real bug")), + pytest.raises(RuntimeError, match="a real bug"), + ): + ss.main(_ARGV) + + +def test_a_total_failure_suggests_rerunning_every_app( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing verified, so nothing may be reported as already working.""" + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE), + patch.object(ss, "seed_apps", side_effect=ss.SeedError("all dead")), + caplog.at_level(logging.INFO, logger="seed_session"), + ): + code = ss.main([*_ARGV, "--app", "diet_guard", "--app", "todo"]) + + assert code == 1 + assert "already working" not in caplog.text + assert "--app diet_guard --app todo" in caplog.text + + +def test_a_partial_failure_reports_what_already_works( + caplog: pytest.LogCaptureFixture, +) -> None: + """The apps that verified must not be reseeded needlessly.""" + partial = ss.SeedError("todo is dead", done=("diet_guard",)) + + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE), + patch.object(ss, "seed_apps", side_effect=partial), + caplog.at_level(logging.INFO, logger="seed_session"), + ): + code = ss.main([*_ARGV, "--app", "diet_guard", "--app", "todo"]) + + assert code == 1 + assert "already working: diet_guard" in caplog.text + + +def test_the_rerun_line_excludes_the_apps_that_already_work( + caplog: pytest.LogCaptureFixture, +) -> None: + """The whole value of the partial report is a copy-pasteable rerun.""" + partial = ss.SeedError("todo is dead", done=("diet_guard",)) + + with ( + patch.object(ss.FirebaseConfig, "load", return_value=_CONFIG), + patch.object(ss, "fetch_id_token", return_value=_ID_VALUE), + patch.object(ss, "seed_apps", side_effect=partial), + caplog.at_level(logging.INFO, logger="seed_session"), + ): + ss.main([*_ARGV, "--app", "diet_guard", "--app", "todo"]) + + assert "rerun with: --app todo" in caplog.text + + +def test_both_credentials_are_required() -> None: + """Neither half of the OAuth client is optional.""" + with pytest.raises(SystemExit): + ss.main(["--client-id", _CLIENT_ID]) diff --git a/crdt-sync/pyproject.toml b/crdt-sync/pyproject.toml index 4cbecf2..ea28ccd 100644 --- a/crdt-sync/pyproject.toml +++ b/crdt-sync/pyproject.toml @@ -125,6 +125,7 @@ addopts = [ "--strict-config", "-ra", "--cov=crdt_sync", + "--cov=tool", "--cov-branch", "--cov-report=term-missing", "--cov-report=lcov", @@ -136,7 +137,10 @@ filterwarnings = [ ] [tool.coverage.run] -source = ["crdt_sync"] +# `tool` is measured but deliberately not installed: [tool.setuptools] ships +# only crdt_sync, so these one-shot scripts stay out of every consumer's +# site-packages. They still run and import as a package from this directory. +source = ["crdt_sync", "tool"] branch = true omit = [ "*/__pycache__/*",