mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
Extracts the three-method remote surface GitHub was reached through into a `RemoteStore` interface, then implements it against Firebase Realtime Database over REST, so apps can move backend with a constructor change. Why REST and not the SDKs: official FlutterFire has no Linux desktop support, and `wake_alarm`'s PC side is a dependency-free systemd job. The RTDB REST endpoints are plain HTTPS and behave identically on Linux, Android and headless. Firebase is a transport, never the datastore -- each app keeps its local log as the source of truth, so offline-first survives. Why RTDB and not Firestore: on the Spark plan RTDB bills only storage and bandwidth with no per-operation quota, so a runaway loop cannot exhaust a daily budget and silently stop syncing mid-day. Two savings make the no-cost tier a guarantee rather than a hope. Measured against the GitHub-backed history this replaces: 4894 of 5542 commits (88.3%) were byte-identical no-ops, and every tick re-downloaded every peer's whole log. Both are now driven by one sha256 revision -- an unchanged log is not re-pushed, and an unchanged peer is not re-fetched. That turns egress from "ticks x log size" (~700 MB/month, and growing with the log) into "edits" (~40 MB/month against a 10 GiB cap). Revisions are one node per device, written only by its owner, rather than one shared map -- a whole-map write would erase every peer's entry, after which those peers would look permanently unchanged and never be fetched again. Per-device keys make that impossible rather than merely avoided. `.` is illegal in an RTDB key and the REST `.json` is a format suffix, so paths get a reversible `~XX` escape (`log.json` -> `log~2Ejson`). Lossy extension-stripping would break `todo`, whose listings return filenames. `MirrorStore` / `MirrorSyncClient` dual-writes during the cutover. Reads consult *both* backends: with primary-only reads a migrated device would never see an un-migrated peer's writes while still mirroring its own back -- silent one-directional convergence with no error raised. Writes are asymmetric on purpose: a primary failure fails the tick, a mirror failure is logged and survived. Auth is Firebase email/password over identitytoolkit REST, with mandatory refresh-token rotation. No service-account key anywhere: it would bypass the security rules and is trivially extractable from an APK. Verified live against a real database, not only mocks: sign-in and refresh, key escaping round-tripping byte-for-byte, an unchanged tick writing nothing, two devices converging, a revoked session throwing rather than returning null, and a log written by the Python client being read by the Dart one. No app is migrated by this commit; every app still runs on GitHub. Dart: 194 tests. Python: 203 tests at 100% branch coverage. No lint suppressions added.
320 lines
13 KiB
Python
320 lines
13 KiB
Python
"""Tests for the Realtime Database sync client.
|
|
|
|
The HTTP layer is fully mocked, so every branch -- success, absent path,
|
|
rules rejection, quota exhaustion, malformed values and network exceptions --
|
|
is exercised without any network access.
|
|
|
|
Key escaping is tested against the same cases as the Dart side
|
|
(``test/firebase_client_test.dart``); the two implementations must agree
|
|
byte-for-byte or a device on one would not read the other's data.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from crdt_sync import (
|
|
DatabaseNotFoundError,
|
|
FirebaseCredentials,
|
|
FirebaseSyncClient,
|
|
FirebaseSyncError,
|
|
FirebaseTokenProvider,
|
|
MemoryCredentialStore,
|
|
RemoteNotFoundError,
|
|
RemoteStore,
|
|
RemoteSyncError,
|
|
)
|
|
from crdt_sync import _firebase as fb
|
|
from crdt_sync._firebase import decode_key, encode_key, encode_path
|
|
|
|
_DATABASE_URL = "https://example-default-rtdb.europe-west1.firebasedatabase.app"
|
|
|
|
# Named without the word "token": ruff's hardcoded-credential checks (S105 /
|
|
# S106) key off the identifier, and these are obviously fake test fixtures.
|
|
_FAKE_ID = "id-fixture"
|
|
_FAKE_REFRESH = "refresh-fixture"
|
|
|
|
|
|
def _response(status_code: int = 200, json_data: object = None) -> MagicMock:
|
|
"""Build a fake ``requests.Response`` with a status and JSON body."""
|
|
response = MagicMock()
|
|
response.status_code = status_code
|
|
response.ok = 200 <= status_code < 300
|
|
response.json = MagicMock(return_value=json_data)
|
|
response.text = json.dumps(json_data)
|
|
return response
|
|
|
|
|
|
def _auth(*, signed_in: bool = True) -> FirebaseTokenProvider:
|
|
"""A provider holding a long-lived token, so no auth HTTP is needed."""
|
|
credentials = (
|
|
FirebaseCredentials(
|
|
id_token=_FAKE_ID,
|
|
refresh_token=_FAKE_REFRESH,
|
|
expires_at=dt.datetime(2099, 1, 1, tzinfo=dt.UTC),
|
|
)
|
|
if signed_in
|
|
else None
|
|
)
|
|
return FirebaseTokenProvider("fake-api-key", MemoryCredentialStore(credentials))
|
|
|
|
|
|
def _client(*, signed_in: bool = True) -> FirebaseSyncClient:
|
|
return FirebaseSyncClient(_DATABASE_URL, _auth(signed_in=signed_in))
|
|
|
|
|
|
class TestKeyEscaping:
|
|
def test_escapes_a_json_filename_into_a_legal_key(self) -> None:
|
|
# `.` is illegal in an RTDB key, and the trailing `.json` in a REST
|
|
# URL is the format suffix -- so the name cannot be stored verbatim.
|
|
assert encode_key("log.json") == "log~2Ejson"
|
|
|
|
def test_leaves_dot_free_device_ids_untouched(self) -> None:
|
|
assert encode_key("pc") == "pc"
|
|
uuid = "32c28cf3-c0eb-423f-8bac-9bda9f158054"
|
|
assert encode_key(uuid) == uuid
|
|
|
|
def test_escapes_every_forbidden_character_and_the_escape_char(self) -> None:
|
|
assert encode_key("a.b$c#d[e]f~g") == "a~2Eb~24c~23d~5Be~5Df~7Eg"
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
["log.json", "plain", "a.b$c#d[e]f~g", "log~2Ejson", "~", ""],
|
|
)
|
|
def test_round_trips(self, name: str) -> None:
|
|
# A name that already looks like an escape must survive: `~` is
|
|
# escaped first, so `log~2Ejson` is not mistaken for `log.json`.
|
|
assert decode_key(encode_key(name)) == name
|
|
|
|
def test_escapes_each_path_segment_but_keeps_separators(self) -> None:
|
|
assert encode_path("todo-sync/notes/abc.json") == "todo-sync/notes/abc~2Ejson"
|
|
|
|
def test_drops_empty_segments(self) -> None:
|
|
assert not encode_path("")
|
|
assert encode_path("/a//b/") == "a/b"
|
|
|
|
|
|
class TestGetFileText:
|
|
def test_returns_the_stored_text_blob(self) -> None:
|
|
with patch.object(fb.requests, "get", return_value=_response(200, '{"a":1}')):
|
|
assert _client().get_file_text("ns/devices/pc/log.json") == '{"a":1}'
|
|
|
|
def test_requests_the_escaped_path_with_the_auth_token(self) -> None:
|
|
with patch.object(
|
|
fb.requests, "get", return_value=_response(200, "{}")
|
|
) as get_mock:
|
|
_client().get_file_text("ns/devices/pc/log.json")
|
|
url = get_mock.call_args.args[0]
|
|
assert url.endswith("/ns/devices/pc/log~2Ejson.json")
|
|
assert get_mock.call_args.kwargs["params"]["auth"] == _FAKE_ID
|
|
|
|
def test_returns_none_for_a_path_never_written_to(self) -> None:
|
|
# RTDB answers a missing path with literal null and a 200, so this is
|
|
# benign "no other device has synced yet", not an error.
|
|
with patch.object(fb.requests, "get", return_value=_response(200, None)):
|
|
assert _client().get_file_text("ns/devices/phone/log.json") is None
|
|
|
|
def test_raises_when_the_value_is_not_a_text_blob(self) -> None:
|
|
with (
|
|
patch.object(fb.requests, "get", return_value=_response(200, {"a": 1})),
|
|
pytest.raises(FirebaseSyncError, match="not the expected text blob"),
|
|
):
|
|
_client().get_file_text("ns/devices/pc/log.json")
|
|
|
|
@pytest.mark.parametrize("status", [401, 403])
|
|
def test_raises_database_not_found_when_the_rules_reject(self, status: int) -> None:
|
|
with (
|
|
patch.object(
|
|
fb.requests, "get", return_value=_response(status, {"error": "denied"})
|
|
),
|
|
pytest.raises(DatabaseNotFoundError),
|
|
):
|
|
_client().get_file_text("ns/devices/pc/log.json")
|
|
|
|
def test_raises_a_plain_sync_error_on_any_other_non_2xx(self) -> None:
|
|
# Spark quota exhaustion lands here. It must surface, never be
|
|
# swallowed into a None that reads as "nothing to sync".
|
|
with (
|
|
patch.object(
|
|
fb.requests, "get", return_value=_response(429, {"error": "quota"})
|
|
),
|
|
pytest.raises(FirebaseSyncError, match="429"),
|
|
):
|
|
_client().get_file_text("ns/devices/pc/log.json")
|
|
|
|
def test_turns_a_network_failure_into_a_sync_error(self) -> None:
|
|
with (
|
|
patch.object(
|
|
fb.requests, "get", side_effect=requests.ConnectionError("offline")
|
|
),
|
|
pytest.raises(FirebaseSyncError, match="network error"),
|
|
):
|
|
_client().get_file_text("ns/devices/pc/log.json")
|
|
|
|
|
|
class TestListDirectory:
|
|
def test_returns_decoded_keys_and_asks_for_a_shallow_read(self) -> None:
|
|
payload = {"pc": True, "phone": True}
|
|
with patch.object(
|
|
fb.requests, "get", return_value=_response(200, payload)
|
|
) as get_mock:
|
|
assert _client().list_directory("ns/devices") == ["pc", "phone"]
|
|
# shallow=true keeps a listing costing bytes instead of the whole
|
|
# subtree -- the difference between ~40 MB/month and ~700 MB.
|
|
assert get_mock.call_args.kwargs["params"]["shallow"] == "true"
|
|
|
|
def test_decodes_escaped_filenames_back(self) -> None:
|
|
# The notes app lists filenames, not device dirs: `.json` returns.
|
|
with patch.object(
|
|
fb.requests, "get", return_value=_response(200, {"abc~2Ejson": True})
|
|
):
|
|
assert _client().list_directory("todo-sync/notes") == ["abc.json"]
|
|
|
|
def test_returns_empty_for_an_unwritten_prefix(self) -> None:
|
|
with patch.object(fb.requests, "get", return_value=_response(200, None)):
|
|
assert not _client().list_directory("ns/devices")
|
|
|
|
def test_raises_on_a_non_2xx_response(self) -> None:
|
|
with (
|
|
patch.object(
|
|
fb.requests, "get", return_value=_response(500, {"error": "boom"})
|
|
),
|
|
pytest.raises(FirebaseSyncError),
|
|
):
|
|
_client().list_directory("ns/devices")
|
|
|
|
|
|
class TestPutFileText:
|
|
def test_writes_the_text_as_a_json_string_leaf(self) -> None:
|
|
with (
|
|
patch.object(fb.requests, "get", return_value=_response(200, None)),
|
|
patch.object(
|
|
fb.requests, "put", return_value=_response(200, '{"a":1}')
|
|
) as put_mock,
|
|
):
|
|
_client().put_file_text(
|
|
"ns/devices/pc/log.json", '{"a":1}', message="ignored"
|
|
)
|
|
assert put_mock.call_args.kwargs["json"] == '{"a":1}'
|
|
assert put_mock.call_args.args[0].endswith("/log~2Ejson.json")
|
|
|
|
def test_raises_on_a_non_2xx_response(self) -> None:
|
|
with (
|
|
patch.object(
|
|
fb.requests, "put", return_value=_response(400, {"error": "bad"})
|
|
),
|
|
pytest.raises(FirebaseSyncError),
|
|
):
|
|
_client().put_file_text("ns/devices/pc/log.json", "{}", message="m")
|
|
|
|
def test_turns_a_network_failure_into_a_sync_error(self) -> None:
|
|
with (
|
|
patch.object(
|
|
fb.requests, "put", side_effect=requests.ConnectionError("offline")
|
|
),
|
|
pytest.raises(FirebaseSyncError, match="network error"),
|
|
):
|
|
_client().put_file_text("ns/devices/pc/log.json", "{}", message="m")
|
|
|
|
|
|
class TestGetStringMap:
|
|
def test_returns_the_map_with_keys_decoded(self) -> None:
|
|
payload = {"pc": "sha-1", "phone": "sha-2"}
|
|
with patch.object(fb.requests, "get", return_value=_response(200, payload)):
|
|
assert _client().get_string_map("ns/revs") == payload
|
|
|
|
def test_degrades_to_empty_when_absent(self) -> None:
|
|
with patch.object(fb.requests, "get", return_value=_response(200, None)):
|
|
assert not _client().get_string_map("ns/revs")
|
|
|
|
def test_skips_non_string_entries_rather_than_failing(self) -> None:
|
|
# The revs node is an optimisation; a corrupt one must degrade into
|
|
# "fetch everything", never into a failed tick.
|
|
with patch.object(
|
|
fb.requests, "get", return_value=_response(200, {"pc": "sha-1", "b": 42})
|
|
):
|
|
assert _client().get_string_map("ns/revs") == {"pc": "sha-1"}
|
|
|
|
def test_raises_on_a_non_2xx_response(self) -> None:
|
|
with (
|
|
patch.object(
|
|
fb.requests, "get", return_value=_response(500, {"error": "boom"})
|
|
),
|
|
pytest.raises(FirebaseSyncError),
|
|
):
|
|
_client().get_string_map("ns/revs")
|
|
|
|
|
|
class TestDeleteFile:
|
|
def test_sends_a_delete_to_the_escaped_path(self) -> None:
|
|
with patch.object(
|
|
fb.requests, "delete", return_value=_response(200, None)
|
|
) as delete_mock:
|
|
_client().delete_file("ns/devices/pc/log.json")
|
|
assert delete_mock.call_args.args[0].endswith("/log~2Ejson.json")
|
|
|
|
def test_raises_on_a_non_2xx_response(self) -> None:
|
|
with (
|
|
patch.object(
|
|
fb.requests, "delete", return_value=_response(500, {"error": "boom"})
|
|
),
|
|
pytest.raises(FirebaseSyncError),
|
|
):
|
|
_client().delete_file("ns/devices/pc/log.json")
|
|
|
|
def test_turns_a_network_failure_into_a_sync_error(self) -> None:
|
|
with (
|
|
patch.object(
|
|
fb.requests, "delete", side_effect=requests.ConnectionError("x")
|
|
),
|
|
pytest.raises(FirebaseSyncError, match="network error"),
|
|
):
|
|
_client().delete_file("ns/devices/pc/log.json")
|
|
|
|
|
|
class TestCanAccessRemote:
|
|
def test_true_when_the_database_root_reads(self) -> None:
|
|
with patch.object(fb.requests, "get", return_value=_response(200, {"ns": 1})):
|
|
assert _client().can_access_remote() is True
|
|
|
|
def test_false_when_the_rules_reject_the_token(self) -> None:
|
|
with patch.object(
|
|
fb.requests, "get", return_value=_response(401, {"error": "denied"})
|
|
):
|
|
assert _client().can_access_remote() is False
|
|
|
|
def test_false_on_a_network_error(self) -> None:
|
|
with patch.object(
|
|
fb.requests, "get", side_effect=requests.ConnectionError("offline")
|
|
):
|
|
assert _client().can_access_remote() is False
|
|
|
|
def test_false_when_not_signed_in_rather_than_raising(self) -> None:
|
|
# "cannot get a token" is exactly "cannot access the remote", and a
|
|
# settings Test-connection button must not blow up.
|
|
assert _client(signed_in=False).can_access_remote() is False
|
|
|
|
|
|
class TestRemoteStoreContract:
|
|
def test_the_client_satisfies_remote_store(self) -> None:
|
|
assert isinstance(_client(), RemoteStore)
|
|
|
|
def test_database_not_found_is_catchable_as_either_error(self) -> None:
|
|
error = DatabaseNotFoundError("gone")
|
|
assert isinstance(error, FirebaseSyncError)
|
|
assert isinstance(error, RemoteNotFoundError)
|
|
assert isinstance(error, RemoteSyncError)
|
|
|
|
def test_a_trailing_slash_on_the_database_url_is_tolerated(self) -> None:
|
|
client = FirebaseSyncClient(f"{_DATABASE_URL}/", _auth())
|
|
with patch.object(
|
|
fb.requests, "get", return_value=_response(200, "ok")
|
|
) as get_mock:
|
|
client.get_file_text("ns/x.json")
|
|
assert get_mock.call_args.args[0] == f"{_DATABASE_URL}/ns/x~2Ejson.json"
|