From f7bf4c972ebce2c362186a1a9f3ab43a4fa06a2a Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:47:05 -0700 Subject: [PATCH] fix(pairing): treat null approved/pending maps as empty --- nanobot/pairing/store.py | 35 ++++++++++++++++++++++++++++++----- tests/pairing/test_store.py | 24 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/nanobot/pairing/store.py b/nanobot/pairing/store.py index afafb1ec..c26f4e0b 100644 --- a/nanobot/pairing/store.py +++ b/nanobot/pairing/store.py @@ -44,8 +44,18 @@ def _load() -> dict[str, Any]: logger.warning("Corrupted pairing store, resetting") return {"approved": {}, "pending": {}} + # JSON stores may contain null maps after partial edits; treat like {}. + approved = data.get("approved") or {} + if not isinstance(approved, dict): + approved = {} + data["approved"] = approved + pending = data.get("pending") or {} + if not isinstance(pending, dict): + pending = {} + data["pending"] = pending + # Convert approved lists to str sets for O(1) lookup. - for channel, users in data.get("approved", {}).items(): + for channel, users in approved.items(): if not isinstance(users, list): users = [] data["approved"][channel] = {str(u) for u in users} @@ -56,9 +66,15 @@ def _save(data: dict[str, Any]) -> None: path = _store_path() path.parent.mkdir(parents=True, exist_ok=True) # Convert sets back to lists for JSON serialization + approved = data.get("approved") or {} + pending = data.get("pending") or {} + if not isinstance(approved, dict): + approved = {} + if not isinstance(pending, dict): + pending = {} payload = { - "approved": {ch: sorted(list(users)) for ch, users in data.get("approved", {}).items()}, - "pending": dict(data.get("pending", {})), + "approved": {ch: sorted(list(users)) for ch, users in approved.items()}, + "pending": dict(pending), } _write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False)) @@ -66,10 +82,18 @@ def _save(data: dict[str, Any]) -> None: def _gc_pending(data: dict[str, Any]) -> None: """Remove expired pending entries in-place.""" now = time.time() - pending: dict[str, Any] = data.get("pending", {}) - expired = [code for code, info in pending.items() if info.get("expires_at", 0) < now] + pending: dict[str, Any] = data.get("pending") or {} + if not isinstance(pending, dict): + data["pending"] = {} + return + expired = [ + code + for code, info in pending.items() + if not isinstance(info, dict) or info.get("expires_at", 0) < now + ] for code in expired: del pending[code] + data["pending"] = pending def generate_code( @@ -152,6 +176,7 @@ def list_pending() -> list[dict[str, Any]]: return [ {"code": code, **info} for code, info in data.get("pending", {}).items() + if isinstance(info, dict) ] diff --git a/tests/pairing/test_store.py b/tests/pairing/test_store.py index 56c84f5d..766657f6 100644 --- a/tests/pairing/test_store.py +++ b/tests/pairing/test_store.py @@ -257,3 +257,27 @@ def test_load_treats_null_approved_channel_list_as_empty(tmp_path, monkeypatch): assert store.is_approved("telegram", "123") is False assert store.is_approved("discord", "456") is True assert store.get_approved("telegram") == [] + + +def test_load_treats_null_approved_and_pending_maps_as_empty(tmp_path, monkeypatch): + """Top-level approved/pending null must not crash pairing load or list_pending.""" + path = tmp_path / "pairing.json" + path.write_text( + '{"approved": null, "pending": null}', + encoding="utf-8", + ) + monkeypatch.setattr(store, "_store_path", lambda: path) + assert store.is_approved("telegram", "123") is False + assert store.list_pending() == [] + assert store.get_approved("telegram") == [] + + +def test_list_pending_skips_null_pending_entries(tmp_path, monkeypatch): + """Null pending entry values must be dropped instead of crashing list_pending.""" + path = tmp_path / "pairing.json" + path.write_text( + '{"approved": {}, "pending": {"ABCD-EFGH": null}}', + encoding="utf-8", + ) + monkeypatch.setattr(store, "_store_path", lambda: path) + assert store.list_pending() == []