utils/crdt-sync/crdt_sync/tests/test_tool_link_google.py
Krzysztof kuhy Rudnicki 7637e46c6f
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Stop the link_google tests reading the real password file
CI caught this: 8 tests failed on the runner with "/home/runner/.config/
crdt-sync/password could not be read". They passed here for the worst possible
reason -- that file exists on this machine, so the tests were reading the
user's actual sync password.

_CONFIG is a real FirebaseConfig, and link_google() calls
config.read_password(), which falls back to PASSWORD_FILE when given no path.
The linking tests now pass a MagicMock(spec=FirebaseConfig) with read_password
stubbed, which is what the one test that already asserted on the password was
doing; that test now shares the helper instead of keeping its own copy.

Adding the helper pushed the file to 265 lines, so the token-inspection tests
move into test_tool_link_google_token.py -- a three-way split matching the
module's three concerns (inspect / link / CLI) at 196, 82 and 223 lines.

Also fixes a mypy finding in the seed_session ordering test, which used
`list.append(...) or "value"` to record call order in a lambda. append returns
None, so the expression only worked by accident; replaced with named functions.

Verified the way the earlier local run could not: HOME pointed at an empty
temp directory, reproducing the runner. 432 passed, 1333 statements and 234
branches at 100%. ~/.config/crdt-sync/password untouched (mtime still Aug 4).

Worth keeping as a rule: for code that reads ~/.config, a green local run
proves nothing unless HOME is redirected first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 16:50:59 +02:00

197 lines
7.2 KiB
Python

"""Tests for token inspection and linking in ``tool/link_google``.
``main()`` and ``_read_token`` are covered in
:mod:`test_tool_link_google_main`, split to stay under the 250-line cap.
The failure this whole script exists to prevent is silent: linking onto a
*new* account succeeds at the auth layer and then has every read and write
denied by rules that pin one uid. So the two assertions that matter most here
are that the existing account's ``idToken`` is always sent (omitting it turns
the call into "sign in or sign up") and that a mismatched uid is a hard error.
"""
from __future__ import annotations
import base64
import json
from unittest.mock import MagicMock, patch
import pytest
import requests
from crdt_sync import FirebaseConfig
from tool import link_google as lg
_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",
)
def _jwt(claims: dict[str, object]) -> str:
"""Build an unsigned JWT carrying ``claims`` as its payload."""
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode()
return f"header.{payload.rstrip('=')}.signature"
def _google_token(**overrides: object) -> str:
"""A plausible Google ID token, with individual claims overridden."""
claims = {
"iss": "https://accounts.google.com",
"email": "person@gmail.com",
"aud": "the-web-client-id.apps.googleusercontent.com",
}
return _jwt({**claims, **overrides})
def _response(*, ok: bool = True, status: int = 200, body: object = None) -> MagicMock:
"""Fake a ``requests`` response from accounts:signInWithIdp."""
response = MagicMock()
response.ok = ok
response.status_code = status
response.text = "" if ok else '{"error": {"message": "INVALID_IDP_RESPONSE"}}'
response.json.return_value = body if body is not None else {"localId": _UID}
return response
def _config_with_password() -> MagicMock:
"""A config whose password is supplied rather than read from disk.
``read_password()`` falls back to the real ``~/.config/crdt-sync/
password``, so the plain dataclass passes only on a machine that happens
to have one -- by reading the user's own password. CI caught that.
"""
config = MagicMock(spec=FirebaseConfig)
config.api_key = _CONFIG.api_key
config.email = _CONFIG.email
config.uid = _CONFIG.uid
config.read_password.return_value = "the-stored-password"
return config
def _auth_provider() -> MagicMock:
"""A token provider that signs in and yields the account's ID token."""
auth = MagicMock()
auth.id_token.return_value = "the-existing-account-id-token"
return auth
def test_linking_returns_the_unchanged_uid() -> None:
"""The happy path: same uid before and after."""
with (
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
patch.object(lg.requests, "post", return_value=_response()) as posted,
):
result = lg.link_google(_config_with_password(), _google_token())
assert result == _UID
assert posted.call_count == 1
def test_the_existing_id_token_is_always_sent() -> None:
"""Omitting it makes the same endpoint create a brand-new account.
That is the silent failure the whole script exists to prevent, so it is
asserted on the request body rather than inferred from the response.
"""
with (
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
patch.object(lg.requests, "post", return_value=_response()) as posted,
):
lg.link_google(_config_with_password(), "the-google-token")
sent = posted.call_args.kwargs["json"]
assert sent["idToken"] == "the-existing-account-id-token"
assert "id_token=the-google-token" in sent["postBody"]
assert "providerId=google.com" in sent["postBody"]
assert sent["returnSecureToken"] is True
def test_the_account_password_is_used_to_prove_ownership() -> None:
"""Linking requires proving ownership of the account being linked to."""
auth = _auth_provider()
config = _config_with_password()
with (
patch.object(lg, "FirebaseTokenProvider", return_value=auth),
patch.object(lg.requests, "post", return_value=_response()),
):
lg.link_google(config, _google_token())
auth.sign_in.assert_called_once_with(_CONFIG.email, "the-stored-password")
def test_linking_onto_a_different_uid_is_a_hard_failure() -> None:
"""The decisive check.
A different uid authenticates perfectly and is then denied every read and
write, so the message has to say that outright and name the remedy.
"""
body = {"localId": "a-brand-new-uid"}
with (
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
patch.object(lg.requests, "post", return_value=_response(body=body)),
pytest.raises(lg.LinkError, match="LINKED THE WRONG ACCOUNT"),
):
lg.link_google(_config_with_password(), _google_token())
def test_the_wrong_account_message_names_the_unlink_remedy() -> None:
"""Knowing it failed is not enough; the console step is not obvious."""
with (
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
patch.object(
lg.requests, "post", return_value=_response(body={"localId": "x"})
),
pytest.raises(lg.LinkError, match="Unlink this Google identity"),
):
lg.link_google(_config_with_password(), _google_token())
def test_a_response_without_a_local_id_is_rejected() -> None:
"""No uid at all must not read as a successful link."""
with (
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
patch.object(lg.requests, "post", return_value=_response(body={})),
pytest.raises(lg.LinkError, match="no localId"),
):
lg.link_google(_config_with_password(), _google_token())
def test_a_rejected_link_reports_the_status_and_body() -> None:
"""Firebase's own message is the only clue to why it refused."""
response = _response(ok=False, status=400)
with (
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
patch.object(lg.requests, "post", return_value=response),
pytest.raises(lg.LinkError, match="HTTP 400"),
):
lg.link_google(_config_with_password(), _google_token())
def test_a_network_error_is_wrapped_as_a_link_error() -> None:
"""A bare requests exception would escape main()'s handler."""
with (
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
patch.object(lg.requests, "post", side_effect=requests.Timeout("timed out")),
pytest.raises(lg.LinkError, match="network error"),
):
lg.link_google(_config_with_password(), _google_token())
def test_the_timeout_is_passed_through() -> None:
"""A hung link would otherwise block the run indefinitely."""
with (
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
patch.object(lg.requests, "post", return_value=_response()) as posted,
):
lg.link_google(_config_with_password(), _google_token(), timeout_seconds=5)
assert posted.call_args.kwargs["timeout"] == 5