utils/crdt-sync/crdt_sync/tests/test_config.py
Krzysztof kuhy Rudnicki dd6e6d5571 crdt_sync 0.5.0: the shared config and the cutover seam
Everything an app needs to move from GitHub to Firebase without each one
reinventing the wiring.

Python:
- _config.py: FirebaseConfig.load() + firebase_client_for(app) +
  mirror_client_for(app, github). One config file, read in one place.
  Credential caches stay per-app (~/.config/<app>/firebase_auth.json) --
  one file written by several processes is a corruption waiting to happen.
- tool/preflight_firebase.py: validates the config before any data moves.
  Catches a wrong-region databaseUrl (which answers 404 with a correctUrl
  body rather than an error, so it would otherwise pass every check and
  break mid-migration) and confirms the uid in the ID token matches the uid
  the rules pin -- sign_in() returns None and discards localId, so the uid
  is decoded from the JWT.
- tool/interop_seed.py: the missing Python half of the cross-language
  check. interop_check.dart asserts it can see a record written by Python,
  but nothing ever wrote one, so it could only ever fail.

Dart:
- firebase_config.dart: the same config shape, kept free of dart:io so it
  works on web and Android, where the file does not exist at all and
  credentials belong in flutter_secure_storage.
- PersistedSyncStateStore: the revision cache, persisted. Without it a
  phone forgets every peer revision on each cold start and re-downloads
  everything -- the exact traffic the revision scheme exists to avoid, and
  what the free-tier headroom depends on.

Verified live against the real database: preflight passes all five checks,
the Dart smoke test passes end to end, and Python <-> Dart interop is OK.

Python 226 tests at 100% branch coverage; Dart 218 tests, both new files
at 100% line coverage.

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

286 lines
9.5 KiB
Python

"""Tests for the shared Firebase configuration loader.
Every failure mode here is one that would otherwise surface as an
authentication error long after the real mistake (a placeholder left in, a
newline a text editor appended), so each is asserted to name the field at
fault rather than merely to raise.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from crdt_sync import (
ConfigError,
FirebaseConfig,
FirebaseSyncClient,
credential_store_for,
mirror_client_for,
)
from crdt_sync._config import firebase_client_for
if TYPE_CHECKING:
from pathlib import Path
_VALID = {
"apiKey": "AIzaSyExample",
"databaseUrl": "https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app",
"projectId": "kuhy-syncs",
"uid": "OvA2REQyLIhAHOEjzwS1o877rgG3",
"email": "sync@example.com",
}
class _StubRemote:
"""A do-nothing :class:`~crdt_sync.RemoteStore`, standing in for GitHub.
A real implementation rather than a bare ``object()`` so the type checker
accepts it without a suppression.
"""
def list_directory(self, path: str) -> list[str]:
return []
def get_file_text(self, path: str) -> str | None:
return None
def put_file_text(self, path: str, text: str, *, message: str) -> None:
"""Accept and discard a write."""
def delete_file(self, path: str, *, message: str = "") -> None:
"""Accept and discard a delete."""
def can_access_remote(self) -> bool:
return True
def _write(path: Path, data: object) -> Path:
"""Write ``data`` as JSON to ``path`` and return it."""
path.write_text(json.dumps(data), encoding="utf-8")
return path
def test_loads_a_valid_config(tmp_path: Path) -> None:
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
assert config.api_key == "AIzaSyExample"
assert config.project_id == "kuhy-syncs"
assert config.uid == "OvA2REQyLIhAHOEjzwS1o877rgG3"
assert config.email == "sync@example.com"
assert config.database_url.endswith("europe-west1.firebasedatabase.app")
def test_ignores_the_scaffold_comment_keys(tmp_path: Path) -> None:
"""The shipped scaffold explains each field inline; that is not config."""
annotated = {**_VALID, "_comment_apiKey": "where to find it", "_comment": "x"}
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", annotated))
assert config.api_key == "AIzaSyExample"
def test_reports_a_missing_file(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match="does not exist"):
FirebaseConfig.load(tmp_path / "absent.json")
def test_reports_unreadable_json(tmp_path: Path) -> None:
path = tmp_path / "firebase.json"
path.write_text("{not json", encoding="utf-8")
with pytest.raises(ConfigError, match="not valid JSON"):
FirebaseConfig.load(path)
def test_reports_a_non_object_document(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match="must contain a JSON object"):
FirebaseConfig.load(_write(tmp_path / "firebase.json", ["a list"]))
def test_reports_an_unreadable_path(tmp_path: Path) -> None:
"""A directory where a file belongs is an OSError, not a missing file."""
(tmp_path / "firebase.json").mkdir()
with pytest.raises(ConfigError, match="could not be read"):
FirebaseConfig.load(tmp_path / "firebase.json")
@pytest.mark.parametrize("field", sorted(_VALID))
def test_names_a_missing_field(tmp_path: Path, field: str) -> None:
incomplete = {k: v for k, v in _VALID.items() if k != field}
with pytest.raises(ConfigError, match=field):
FirebaseConfig.load(_write(tmp_path / "firebase.json", incomplete))
def test_names_an_empty_field(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match="uid"):
FirebaseConfig.load(_write(tmp_path / "firebase.json", {**_VALID, "uid": " "}))
def test_names_an_unfilled_placeholder(tmp_path: Path) -> None:
"""The exact state the scaffold ships in must not load silently."""
unfilled = {**_VALID, "apiKey": "PASTE_WEB_API_KEY_HERE"}
with pytest.raises(ConfigError, match=r"placeholder for: apiKey"):
FirebaseConfig.load(_write(tmp_path / "firebase.json", unfilled))
def test_reads_a_password(tmp_path: Path) -> None:
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
password = tmp_path / "password"
password.write_text("hunter2", encoding="utf-8")
assert config.read_password(password) == "hunter2"
def test_strips_a_trailing_newline_from_the_password(tmp_path: Path) -> None:
"""The likeliest hand-editing mistake, and otherwise invisible."""
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
password = tmp_path / "password"
password.write_text("hunter2\n", encoding="utf-8")
assert config.read_password(password) == "hunter2"
def test_rejects_an_empty_password(tmp_path: Path) -> None:
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
password = tmp_path / "password"
password.write_text(" ", encoding="utf-8")
with pytest.raises(ConfigError, match="empty or still holds"):
config.read_password(password)
def test_rejects_a_placeholder_password(tmp_path: Path) -> None:
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
password = tmp_path / "password"
password.write_text("PASTE_SYNC_ACCOUNT_PASSWORD_HERE", encoding="utf-8")
with pytest.raises(ConfigError, match="placeholder"):
config.read_password(password)
def test_reports_an_unreadable_password(tmp_path: Path) -> None:
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
(tmp_path / "password").mkdir()
with pytest.raises(ConfigError, match="could not be read"):
config.read_password(tmp_path / "password")
def test_credential_store_is_per_app() -> None:
"""Two apps must not share one cache file; concurrent writes corrupt it."""
diet = credential_store_for("diet_guard")
alarm = credential_store_for("wake_alarm")
assert diet._path != alarm._path
assert diet._path.name == "firebase_auth.json"
assert "diet_guard" in str(diet._path)
def test_client_signs_in_only_without_a_cached_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The common path is a cached refresh token, costing no round trip."""
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
sign_ins: list[tuple[str, str]] = []
class _Auth:
def __init__(self, api_key: str, store: object) -> None:
self.api_key = api_key
def has_session(self) -> bool:
return True
def sign_in(self, email: str, password: str) -> None:
sign_ins.append((email, password)) # pragma: no cover - must not run
monkeypatch.setattr("crdt_sync._config.FirebaseTokenProvider", _Auth)
client = firebase_client_for("diet_guard", config=config)
assert sign_ins == []
assert client is not None
def test_client_signs_in_when_there_is_no_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
password = tmp_path / "password"
password.write_text("hunter2", encoding="utf-8")
monkeypatch.setattr("crdt_sync._config.PASSWORD_FILE", password)
sign_ins: list[tuple[str, str]] = []
class _Auth:
def __init__(self, api_key: str, store: object) -> None:
self.api_key = api_key
def has_session(self) -> bool:
return False
def sign_in(self, email: str, password_value: str) -> None:
sign_ins.append((email, password_value))
monkeypatch.setattr("crdt_sync._config.FirebaseTokenProvider", _Auth)
firebase_client_for("diet_guard", config=config)
assert sign_ins == [("sync@example.com", "hunter2")]
def test_mirror_client_makes_firebase_primary(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Firebase authoritative, GitHub best-effort -- the cutover shape."""
config = FirebaseConfig.load(_write(tmp_path / "firebase.json", _VALID))
github = _StubRemote()
class _Auth:
def __init__(self, api_key: str, store: object) -> None:
self.api_key = api_key
def has_session(self) -> bool:
return True
def sign_in(self, email: str, password: str) -> None:
raise AssertionError # pragma: no cover - must not run
monkeypatch.setattr("crdt_sync._config.FirebaseTokenProvider", _Auth)
client = mirror_client_for("diet_guard", github, config=config)
# Reaching into the wrapper is the point: which backend is authoritative
# is the one thing a rollback depends on getting right.
assert client.mirror is github
assert isinstance(client.primary, FirebaseSyncClient)
def test_client_loads_the_shared_config_when_none_is_given(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The default path: apps name themselves and nothing else."""
monkeypatch.setattr(
"crdt_sync._config.CONFIG_FILE",
_write(tmp_path / "firebase.json", _VALID),
)
class _Auth:
def __init__(self, api_key: str, store: object) -> None:
self.api_key = api_key
def has_session(self) -> bool:
return True
def sign_in(self, email: str, password: str) -> None:
raise AssertionError # pragma: no cover - must not run
monkeypatch.setattr("crdt_sync._config.FirebaseTokenProvider", _Auth)
assert firebase_client_for("wake_alarm") is not None