fix(pairing): keep approvals across transient store read failures

_load() treated any OSError like corruption and returned an empty store. When pairing.json was transiently unreadable, an unapproved DM could deny the sender, generate a pairing code from the empty view, and overwrite the store without its approved senders.

Keep the existing JSONDecodeError reset behavior, but propagate OSError so mutations cannot persist unreadable state. Read-only checks fail closed without writing; mutating /pairing subcommands report temporary unavailability; and the DM pairing path skips one reply instead of crashing the handler.

This mirrors the refuse-to-overwrite strategy used by the cron and trigger stores.
This commit is contained in:
KDB
2026-07-30 19:02:37 +08:00
committed by Xubin Ren
parent e633f867e8
commit 52680dbe19
4 changed files with 144 additions and 5 deletions
+63
View File
@@ -323,3 +323,66 @@ def test_pending_gc_drops_malformed_entries(tmp_path, monkeypatch):
)
monkeypatch.setattr(store, "_store_path", lambda: path)
assert store.list_pending() == []
def _fail_reads_of(monkeypatch, path):
"""Make reads of *path* raise like a transiently locked/busy file."""
import builtins
from pathlib import Path
real_open = builtins.open
def flaky_open(file, mode="r", *args, **kwargs):
try:
same = Path(file) == path
except TypeError:
same = False
if same and "r" in mode and "+" not in mode:
raise PermissionError(13, "temporarily locked", str(path))
return real_open(file, mode, *args, **kwargs)
monkeypatch.setattr(builtins, "open", flaky_open)
class TestTransientReadFailure:
"""A transient I/O failure is not corruption and must never wipe the store."""
def test_generate_code_does_not_wipe_approvals(self, tmp_path, monkeypatch):
"""An unapproved DM during a read blip previously erased every approval.
_load treated OSError like corruption and returned an empty store;
generate_code then unconditionally saved it, overwriting pairing.json
with no approved senders.
"""
code = store.generate_code("telegram", "123")
store.approve_code(code)
with monkeypatch.context() as m:
_fail_reads_of(m, store._store_path())
with pytest.raises(OSError):
store.generate_code("telegram", "stranger")
assert store.is_approved("telegram", "123") is True
def test_reads_fail_closed_without_crashing(self, tmp_path, monkeypatch):
code = store.generate_code("telegram", "123")
store.approve_code(code)
with monkeypatch.context() as m:
_fail_reads_of(m, store._store_path())
assert store.is_approved("telegram", "123") is False
assert store.list_pending() == []
assert store.get_approved("telegram") == []
assert store.is_approved("telegram", "123") is True
def test_approve_command_reports_store_unavailable(self, tmp_path, monkeypatch):
"""/pairing approve must fail loudly instead of claiming the code is invalid."""
code = store.generate_code("telegram", "123")
with monkeypatch.context() as m:
_fail_reads_of(m, store._store_path())
reply = store.handle_pairing_command("telegram", f"approve {code}")
assert "unavailable" in reply.lower()
assert store.approve_code(code) == ("telegram", "123")