mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 15:34:02 +01:00
Fifth module of the tool/ coverage task, split into the GitHub-reading half and main() at 216 and 248 lines. This is a one-shot migration whose worst outcome is reporting success while having moved the wrong bytes, so the tests pin the decisions that prevent exactly that: reading blobs by sha rather than by path (the contents API would return whatever is at that path *now*, which need not be what the tree listing described), refusing a truncated tree instead of migrating part of the repo, never fetching a skipped path, emitting the report before the first write, and failing the run when the byte-for-byte read-back disagrees -- including the partial case, where one bad file out of two is a failed migration rather than a 50% success. Also fixes the S105 finding the previous commit's preflight tests carried: `_PASSWORD` trips the hardcoded-credential rule on the binding name, the same trap this task's notes already record. Renamed rather than suppressed. The blanket rename briefly caught the module attribute `_PASSWORD_FILE` too, which the suite caught immediately. crdt_sync 333 -> 365 passed, still 100%. 5 of 7 tool/ modules done; the two remaining are link_google.py and seed_session.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
248 lines
9.1 KiB
Python
248 lines
9.1 KiB
Python
"""Tests for the individual checks in ``tool/preflight_firebase``.
|
|
|
|
``main()`` and the loading helpers are covered in
|
|
:mod:`test_tool_preflight_firebase_main`, split to stay under the 250-line cap.
|
|
|
|
The point of this tool is that each check names one specific misconfiguration,
|
|
because the failures it catches are otherwise indistinguishable once data has
|
|
started moving. So every check is asserted on its *message*, not just on the
|
|
fact that it raised -- a check that fires with the wrong explanation sends the
|
|
reader to the wrong place, which is the cost this module exists to avoid.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from tool import preflight_firebase as pf
|
|
|
|
_UID = "the-uid-pinned-in-the-rules"
|
|
_GOOD = {
|
|
"apiKey": "AIzaSyAthisIsTheWebApiKeyFormat",
|
|
"databaseUrl": "https://project-default-rtdb.europe-west1.firebasedatabase.app",
|
|
"projectId": "the-project",
|
|
"uid": _UID,
|
|
"email": "sync@example.com",
|
|
}
|
|
_ACCOUNT_PHRASE = "the-sync-account-password"
|
|
|
|
|
|
def _config(**overrides: str) -> dict[str, str]:
|
|
"""A valid config with individual fields overridden."""
|
|
return {**_GOOD, **overrides}
|
|
|
|
|
|
def test_keys_present_accepts_a_complete_config() -> None:
|
|
"""The happy path raises nothing."""
|
|
pf.check_keys_present(_config())
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["apiKey", "databaseUrl", "projectId", "uid", "email"])
|
|
def test_every_required_key_is_actually_required(key: str) -> None:
|
|
"""Each of the five keys is checked, not just the first."""
|
|
config = _config()
|
|
del config[key]
|
|
|
|
with pytest.raises(pf.PreflightError, match=key):
|
|
pf.check_keys_present(config)
|
|
|
|
|
|
def test_a_whitespace_only_value_counts_as_missing() -> None:
|
|
"""A key present but blank is the same failure as an absent one."""
|
|
with pytest.raises(pf.PreflightError, match="uid"):
|
|
pf.check_keys_present(_config(uid=" "))
|
|
|
|
|
|
def test_all_missing_keys_are_reported_together() -> None:
|
|
"""Reporting one at a time would need five runs to fix five typos."""
|
|
config = {"apiKey": _GOOD["apiKey"]}
|
|
|
|
with pytest.raises(pf.PreflightError) as raised:
|
|
pf.check_keys_present(config)
|
|
|
|
assert "databaseUrl" in str(raised.value)
|
|
assert "email" in str(raised.value)
|
|
|
|
|
|
def test_no_placeholders_accepts_a_filled_in_config() -> None:
|
|
"""Real values pass."""
|
|
pf.check_no_placeholders(_config(), _ACCOUNT_PHRASE)
|
|
|
|
|
|
def test_an_unreplaced_scaffold_placeholder_is_caught() -> None:
|
|
"""The scaffold ships PASTE_ markers; one left behind is a typo."""
|
|
with pytest.raises(pf.PreflightError, match="projectId"):
|
|
pf.check_no_placeholders(_config(projectId="PASTE_PROJECT_ID"), _ACCOUNT_PHRASE)
|
|
|
|
|
|
def test_a_placeholder_in_the_password_file_is_caught() -> None:
|
|
"""The password lives in its own file and is easy to forget."""
|
|
with pytest.raises(pf.PreflightError, match="password"):
|
|
pf.check_no_placeholders(_config(), "PASTE_ACCOUNT_PHRASE_HERE")
|
|
|
|
|
|
def test_shapes_accepts_well_formed_values() -> None:
|
|
"""The happy path raises nothing."""
|
|
pf.check_shapes(_config(), _ACCOUNT_PHRASE)
|
|
|
|
|
|
def test_a_non_https_database_url_is_rejected() -> None:
|
|
"""http:// would work locally and fail everywhere else."""
|
|
with pytest.raises(pf.PreflightError, match="must start with https://"):
|
|
pf.check_shapes(
|
|
_config(databaseUrl="http://project.firebasedatabase.app"), _ACCOUNT_PHRASE
|
|
)
|
|
|
|
|
|
def test_a_trailing_slash_on_the_database_url_is_rejected() -> None:
|
|
"""It yields a double slash in every request path built from it."""
|
|
with pytest.raises(pf.PreflightError, match="must not end with"):
|
|
pf.check_shapes(
|
|
_config(databaseUrl=f"{_GOOD['databaseUrl']}/"), _ACCOUNT_PHRASE
|
|
)
|
|
|
|
|
|
def test_an_api_key_in_the_wrong_format_is_rejected() -> None:
|
|
"""Pasting a service-account key instead of the Web API key."""
|
|
with pytest.raises(pf.PreflightError, match="AIza"):
|
|
pf.check_shapes(_config(apiKey="ya29.not-the-web-api-key"), _ACCOUNT_PHRASE)
|
|
|
|
|
|
def test_a_password_with_a_trailing_newline_is_rejected() -> None:
|
|
"""The single most likely hand-editing mistake, per the module docstring."""
|
|
with pytest.raises(pf.PreflightError, match="whitespace"):
|
|
pf.check_shapes(_config(), f"{_ACCOUNT_PHRASE}\n")
|
|
|
|
|
|
def test_an_email_without_an_at_sign_is_rejected() -> None:
|
|
"""A uid pasted into the email field, typically."""
|
|
with pytest.raises(pf.PreflightError, match="does not look like an address"):
|
|
pf.check_shapes(_config(email="not-an-address"), _ACCOUNT_PHRASE)
|
|
|
|
|
|
def _get(status: int, payload: object = None) -> MagicMock:
|
|
"""Fake a ``requests.get`` response with a status and JSON body."""
|
|
response = MagicMock()
|
|
response.status_code = status
|
|
if isinstance(payload, ValueError):
|
|
response.json.side_effect = payload
|
|
else:
|
|
response.json.return_value = payload if payload is not None else {}
|
|
return response
|
|
|
|
|
|
def test_a_denied_anonymous_read_is_the_expected_outcome() -> None:
|
|
"""401 means the rules are published and doing their job."""
|
|
with patch.object(pf.requests, "get", return_value=_get(401)):
|
|
pf.check_rules_deny_anonymous(_config())
|
|
|
|
|
|
def test_a_world_readable_database_is_reported_loudly() -> None:
|
|
"""A 200 here means anyone on the internet can read the data."""
|
|
with (
|
|
patch.object(pf.requests, "get", return_value=_get(200)),
|
|
pytest.raises(pf.PreflightError, match="world-readable"),
|
|
):
|
|
pf.check_rules_deny_anonymous(_config())
|
|
|
|
|
|
def test_a_wrong_region_url_reports_the_correct_one() -> None:
|
|
"""Firebase answers 404 with the real URL rather than an error.
|
|
|
|
Without this branch the wrong-region case would pass preflight, pass
|
|
sign-in (a different host entirely) and only break mid-migration.
|
|
"""
|
|
body = {
|
|
"correctUrl": "https://project-default-rtdb.asia-southeast1.firebasedatabase.app"
|
|
}
|
|
|
|
with (
|
|
patch.object(pf.requests, "get", return_value=_get(404, body)),
|
|
pytest.raises(pf.PreflightError, match="asia-southeast1"),
|
|
):
|
|
pf.check_rules_deny_anonymous(_config())
|
|
|
|
|
|
def test_a_404_without_a_correct_url_reports_a_missing_database() -> None:
|
|
"""No database at that URL at all."""
|
|
with (
|
|
patch.object(pf.requests, "get", return_value=_get(404)),
|
|
pytest.raises(pf.PreflightError, match="no database lives there"),
|
|
):
|
|
pf.check_rules_deny_anonymous(_config())
|
|
|
|
|
|
def test_a_404_with_an_unparseable_body_still_reports_a_missing_database() -> None:
|
|
"""The body is not guaranteed to be JSON; that must not mask the 404."""
|
|
response = _get(404, ValueError("not json"))
|
|
|
|
with (
|
|
patch.object(pf.requests, "get", return_value=response),
|
|
pytest.raises(pf.PreflightError, match="no database lives there"),
|
|
):
|
|
pf.check_rules_deny_anonymous(_config())
|
|
|
|
|
|
def _token_for(uid: str) -> str:
|
|
"""Build a JWT whose payload carries ``uid`` as ``user_id``."""
|
|
payload = base64.urlsafe_b64encode(json.dumps({"user_id": uid}).encode())
|
|
return f"header.{payload.decode().rstrip('=')}.signature"
|
|
|
|
|
|
def _provider(uid: str) -> MagicMock:
|
|
"""A FirebaseTokenProvider that signs in and yields ``uid``'s token."""
|
|
provider = MagicMock()
|
|
provider.id_token.return_value = _token_for(uid)
|
|
return provider
|
|
|
|
|
|
def test_sign_in_with_the_pinned_uid_passes() -> None:
|
|
"""The decisive check, in its passing form."""
|
|
with patch.object(pf, "FirebaseTokenProvider", return_value=_provider(_UID)):
|
|
pf.check_sign_in_uid_matches(_config(), _ACCOUNT_PHRASE)
|
|
|
|
|
|
def test_signing_in_as_a_different_uid_is_rejected() -> None:
|
|
"""Authenticates fine, then denies every read and write."""
|
|
with (
|
|
patch.object(
|
|
pf, "FirebaseTokenProvider", return_value=_provider("another-uid")
|
|
),
|
|
pytest.raises(pf.PreflightError, match="every read and write would be denied"),
|
|
):
|
|
pf.check_sign_in_uid_matches(_config(), _ACCOUNT_PHRASE)
|
|
|
|
|
|
def test_the_uid_is_read_from_sub_when_user_id_is_absent() -> None:
|
|
"""Not every issuer sets user_id; sub is the standard claim."""
|
|
payload = base64.urlsafe_b64encode(json.dumps({"sub": _UID}).encode())
|
|
provider = MagicMock()
|
|
provider.id_token.return_value = f"h.{payload.decode().rstrip('=')}.s"
|
|
|
|
with patch.object(pf, "FirebaseTokenProvider", return_value=provider):
|
|
pf.check_sign_in_uid_matches(_config(), _ACCOUNT_PHRASE)
|
|
|
|
|
|
def test_the_password_is_passed_to_sign_in_verbatim() -> None:
|
|
"""A stripped or re-encoded password would fail for the wrong reason."""
|
|
provider = _provider(_UID)
|
|
|
|
with patch.object(pf, "FirebaseTokenProvider", return_value=provider):
|
|
pf.check_sign_in_uid_matches(_config(), _ACCOUNT_PHRASE)
|
|
|
|
provider.sign_in.assert_called_once_with(_GOOD["email"], _ACCOUNT_PHRASE)
|
|
|
|
|
|
def test_requests_exceptions_are_not_swallowed_by_the_checks() -> None:
|
|
"""Transport errors belong to main(), which reports them differently."""
|
|
with (
|
|
patch.object(pf.requests, "get", side_effect=requests.ConnectionError("down")),
|
|
pytest.raises(requests.ConnectionError),
|
|
):
|
|
pf.check_rules_deny_anonymous(_config())
|