mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user