mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
Stop the link_google tests reading the real password file
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
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
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
This commit is contained in:
parent
0861aebda9
commit
7637e46c6f
@ -58,68 +58,19 @@ def _response(*, ok: bool = True, status: int = 200, body: object = None) -> Mag
|
||||
return response
|
||||
|
||||
|
||||
def test_a_token_that_is_not_three_parts_is_rejected() -> None:
|
||||
"""An access token or an auth code pasted by mistake.
|
||||
def _config_with_password() -> MagicMock:
|
||||
"""A config whose password is supplied rather than read from disk.
|
||||
|
||||
The message names both, because that is the actual confusion -- the
|
||||
module docstring exists because the Firebase-side error does not.
|
||||
``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.
|
||||
"""
|
||||
with pytest.raises(lg.LinkError, match="not an access token"):
|
||||
lg.describe_google_token("not-a-jwt")
|
||||
|
||||
|
||||
def test_a_token_with_an_unparseable_payload_is_rejected() -> None:
|
||||
"""Three parts, but the middle one is not base64url JSON."""
|
||||
with pytest.raises(lg.LinkError, match="not valid base64url JSON"):
|
||||
lg.describe_google_token("header.!!!not-base64!!!.signature")
|
||||
|
||||
|
||||
def test_a_payload_that_is_not_an_object_is_rejected() -> None:
|
||||
"""Valid base64url JSON, wrong shape."""
|
||||
payload = base64.urlsafe_b64encode(b'"just a string"').decode().rstrip("=")
|
||||
|
||||
with pytest.raises(lg.LinkError, match="not a JSON object"):
|
||||
lg.describe_google_token(f"header.{payload}.signature")
|
||||
|
||||
|
||||
def test_a_token_from_another_issuer_is_rejected() -> None:
|
||||
"""The check that makes a misconfiguration report itself locally."""
|
||||
with pytest.raises(lg.LinkError, match="not Google"):
|
||||
lg.describe_google_token(_google_token(iss="https://login.example.com"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"issuer", ["https://accounts.google.com", "accounts.google.com"]
|
||||
)
|
||||
def test_both_google_issuer_spellings_are_accepted(issuer: str) -> None:
|
||||
"""Google uses both forms; rejecting either would be a false alarm."""
|
||||
assert "person@gmail.com" in lg.describe_google_token(_google_token(iss=issuer))
|
||||
|
||||
|
||||
def test_the_description_names_the_account_and_the_audience() -> None:
|
||||
"""The operator's only chance to notice they are linking the wrong one."""
|
||||
description = lg.describe_google_token(_google_token())
|
||||
|
||||
assert "person@gmail.com" in description
|
||||
assert "the-web-client-id.apps.googleusercontent.com" in description
|
||||
|
||||
|
||||
def test_missing_optional_claims_are_described_rather_than_crashing() -> None:
|
||||
"""A token without email/aud is odd but still describable."""
|
||||
token = _jwt({"iss": "accounts.google.com"})
|
||||
|
||||
description = lg.describe_google_token(token)
|
||||
|
||||
assert "<no email claim>" in description
|
||||
assert "<no aud claim>" in description
|
||||
|
||||
|
||||
def test_padding_is_restored_before_decoding() -> None:
|
||||
"""base64url in a JWT is unpadded; decoding without padding fails."""
|
||||
# A payload whose length is not a multiple of 4 once stripped.
|
||||
claims = {"iss": "accounts.google.com", "email": "a@b.co"}
|
||||
|
||||
assert "a@b.co" in lg.describe_google_token(_jwt(claims))
|
||||
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:
|
||||
@ -135,7 +86,7 @@ def test_linking_returns_the_unchanged_uid() -> None:
|
||||
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
|
||||
patch.object(lg.requests, "post", return_value=_response()) as posted,
|
||||
):
|
||||
result = lg.link_google(_CONFIG, _google_token())
|
||||
result = lg.link_google(_config_with_password(), _google_token())
|
||||
|
||||
assert result == _UID
|
||||
assert posted.call_count == 1
|
||||
@ -151,7 +102,7 @@ def test_the_existing_id_token_is_always_sent() -> None:
|
||||
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
|
||||
patch.object(lg.requests, "post", return_value=_response()) as posted,
|
||||
):
|
||||
lg.link_google(_CONFIG, "the-google-token")
|
||||
lg.link_google(_config_with_password(), "the-google-token")
|
||||
|
||||
sent = posted.call_args.kwargs["json"]
|
||||
assert sent["idToken"] == "the-existing-account-id-token"
|
||||
@ -163,11 +114,7 @@ def test_the_existing_id_token_is_always_sent() -> None:
|
||||
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 = MagicMock(spec=FirebaseConfig)
|
||||
config.api_key = _CONFIG.api_key
|
||||
config.email = _CONFIG.email
|
||||
config.uid = _UID
|
||||
config.read_password.return_value = "the-stored-password"
|
||||
config = _config_with_password()
|
||||
|
||||
with (
|
||||
patch.object(lg, "FirebaseTokenProvider", return_value=auth),
|
||||
@ -191,7 +138,7 @@ def test_linking_onto_a_different_uid_is_a_hard_failure() -> None:
|
||||
patch.object(lg.requests, "post", return_value=_response(body=body)),
|
||||
pytest.raises(lg.LinkError, match="LINKED THE WRONG ACCOUNT"),
|
||||
):
|
||||
lg.link_google(_CONFIG, _google_token())
|
||||
lg.link_google(_config_with_password(), _google_token())
|
||||
|
||||
|
||||
def test_the_wrong_account_message_names_the_unlink_remedy() -> None:
|
||||
@ -203,7 +150,7 @@ def test_the_wrong_account_message_names_the_unlink_remedy() -> None:
|
||||
),
|
||||
pytest.raises(lg.LinkError, match="Unlink this Google identity"),
|
||||
):
|
||||
lg.link_google(_CONFIG, _google_token())
|
||||
lg.link_google(_config_with_password(), _google_token())
|
||||
|
||||
|
||||
def test_a_response_without_a_local_id_is_rejected() -> None:
|
||||
@ -213,7 +160,7 @@ def test_a_response_without_a_local_id_is_rejected() -> None:
|
||||
patch.object(lg.requests, "post", return_value=_response(body={})),
|
||||
pytest.raises(lg.LinkError, match="no localId"),
|
||||
):
|
||||
lg.link_google(_CONFIG, _google_token())
|
||||
lg.link_google(_config_with_password(), _google_token())
|
||||
|
||||
|
||||
def test_a_rejected_link_reports_the_status_and_body() -> None:
|
||||
@ -225,7 +172,7 @@ def test_a_rejected_link_reports_the_status_and_body() -> None:
|
||||
patch.object(lg.requests, "post", return_value=response),
|
||||
pytest.raises(lg.LinkError, match="HTTP 400"),
|
||||
):
|
||||
lg.link_google(_CONFIG, _google_token())
|
||||
lg.link_google(_config_with_password(), _google_token())
|
||||
|
||||
|
||||
def test_a_network_error_is_wrapped_as_a_link_error() -> None:
|
||||
@ -235,7 +182,7 @@ def test_a_network_error_is_wrapped_as_a_link_error() -> None:
|
||||
patch.object(lg.requests, "post", side_effect=requests.Timeout("timed out")),
|
||||
pytest.raises(lg.LinkError, match="network error"),
|
||||
):
|
||||
lg.link_google(_CONFIG, _google_token())
|
||||
lg.link_google(_config_with_password(), _google_token())
|
||||
|
||||
|
||||
def test_the_timeout_is_passed_through() -> None:
|
||||
@ -244,6 +191,6 @@ def test_the_timeout_is_passed_through() -> None:
|
||||
patch.object(lg, "FirebaseTokenProvider", return_value=_auth_provider()),
|
||||
patch.object(lg.requests, "post", return_value=_response()) as posted,
|
||||
):
|
||||
lg.link_google(_CONFIG, _google_token(), timeout_seconds=5)
|
||||
lg.link_google(_config_with_password(), _google_token(), timeout_seconds=5)
|
||||
|
||||
assert posted.call_args.kwargs["timeout"] == 5
|
||||
|
||||
81
crdt-sync/crdt_sync/tests/test_tool_link_google_token.py
Normal file
81
crdt-sync/crdt_sync/tests/test_tool_link_google_token.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Tests for ``describe_google_token`` in ``tool/link_google``.
|
||||
|
||||
Split from :mod:`test_tool_link_google` (which covers linking) and
|
||||
:mod:`test_tool_link_google_main` (the CLI) to stay under the 250-line cap.
|
||||
|
||||
These are the local, pre-network checks. They exist so a misconfiguration
|
||||
reports itself here rather than as an opaque Firebase rejection, so what is
|
||||
asserted is the *message* -- naming what was pasted by mistake is the entire
|
||||
value of the check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from crdt_sync.tests.test_tool_link_google import _google_token, _jwt
|
||||
from tool import link_google as lg
|
||||
|
||||
|
||||
def test_a_token_that_is_not_three_parts_is_rejected() -> None:
|
||||
"""An access token or an auth code pasted by mistake.
|
||||
|
||||
The message names both, because that is the actual confusion -- the
|
||||
module docstring exists because the Firebase-side error does not.
|
||||
"""
|
||||
with pytest.raises(lg.LinkError, match="not an access token"):
|
||||
lg.describe_google_token("not-a-jwt")
|
||||
|
||||
|
||||
def test_a_token_with_an_unparseable_payload_is_rejected() -> None:
|
||||
"""Three parts, but the middle one is not base64url JSON."""
|
||||
with pytest.raises(lg.LinkError, match="not valid base64url JSON"):
|
||||
lg.describe_google_token("header.!!!not-base64!!!.signature")
|
||||
|
||||
|
||||
def test_a_payload_that_is_not_an_object_is_rejected() -> None:
|
||||
"""Valid base64url JSON, wrong shape."""
|
||||
payload = base64.urlsafe_b64encode(b'"just a string"').decode().rstrip("=")
|
||||
|
||||
with pytest.raises(lg.LinkError, match="not a JSON object"):
|
||||
lg.describe_google_token(f"header.{payload}.signature")
|
||||
|
||||
|
||||
def test_a_token_from_another_issuer_is_rejected() -> None:
|
||||
"""The check that makes a misconfiguration report itself locally."""
|
||||
with pytest.raises(lg.LinkError, match="not Google"):
|
||||
lg.describe_google_token(_google_token(iss="https://login.example.com"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("issuer", [*lg._EXPECTED_ISSUERS])
|
||||
def test_both_google_issuer_spellings_are_accepted(issuer: str) -> None:
|
||||
"""Google uses both forms; rejecting either would be a false alarm."""
|
||||
assert "person@gmail.com" in lg.describe_google_token(_google_token(iss=issuer))
|
||||
|
||||
|
||||
def test_the_description_names_the_account_and_the_audience() -> None:
|
||||
"""The operator's only chance to notice they are linking the wrong one."""
|
||||
description = lg.describe_google_token(_google_token())
|
||||
|
||||
assert "person@gmail.com" in description
|
||||
assert "the-web-client-id.apps.googleusercontent.com" in description
|
||||
|
||||
|
||||
def test_missing_optional_claims_are_described_rather_than_crashing() -> None:
|
||||
"""A token without email/aud is odd but still describable."""
|
||||
token = _jwt({"iss": "accounts.google.com"})
|
||||
|
||||
description = lg.describe_google_token(token)
|
||||
|
||||
assert "<no email claim>" in description
|
||||
assert "<no aud claim>" in description
|
||||
|
||||
|
||||
def test_padding_is_restored_before_decoding() -> None:
|
||||
"""base64url in a JWT is unpadded; decoding without padding fails."""
|
||||
# A payload whose length is not a multiple of 4 once stripped.
|
||||
claims = {"iss": "accounts.google.com", "email": "a@b.co"}
|
||||
|
||||
assert "a@b.co" in lg.describe_google_token(_jwt(claims))
|
||||
@ -107,12 +107,16 @@ def test_verification_happens_after_every_session_exists(seeding: tuple) -> None
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
def _record_seed(*_args: object, **_kwargs: object) -> str:
|
||||
order.append("seed")
|
||||
return "person@gmail.com"
|
||||
|
||||
def _record_verify(_path: str) -> None:
|
||||
order.append("verify")
|
||||
|
||||
factory.return_value.sign_in_with_google.side_effect = _record_seed
|
||||
client_factory.return_value.list_directory.side_effect = _record_verify
|
||||
|
||||
ss.seed_apps(_CONFIG, _ID_VALUE, _APPS)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user