Cover tool/seed_session.py and put all of tool/ under the coverage gate

Finishes the tool/ coverage task. seed_session.py is the seventh and last
module, split into seed_apps and main() at 224 and 222 lines.

The tests pin the two structural decisions the module's docstring argues for,
since neither is visible from its output: each app gets its own exchange and
its own credential store (one session copied across them would have every app
refreshing the same rotated token and invalidating the others), and
verification runs as a *second pass* after all sessions exist -- checking each
app as it is written would pass one that a later exchange then invalidates,
which is the exact failure being guarded against. Asserted by recording the
call order and requiring seed,seed,verify,verify.

Also covers the partial-failure path, which is the difference between a
copy-pasteable rerun and a hunt through ~/.config: SeedError carries the apps
that verified, and the rerun line lists the remaining ones and only those.

With every module at 100%, the config flip lands last as planned:

    addopts += "--cov=tool"
    source = ["crdt_sync", "tool"]

Done condition met -- 432 passed, 1333 statements and 234 branches at 100%,
tool/__init__.py included. `import crdt_sync` and `python3 -m
tool.google_id_token --help` both still work after the ruff autofixes, and
`pre-commit --all-files` is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 16:46:40 +02:00
parent 1af1d3005b
commit 6d64f9e3c9
4 changed files with 454 additions and 5 deletions

View File

@ -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(

View File

@ -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

View File

@ -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])

View File

@ -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__/*",