mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
_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.
171 lines
5.0 KiB
Python
171 lines
5.0 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from nanobot.bus.events import OutboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.channels.base import BaseChannel
|
|
|
|
|
|
class _DummyChannel(BaseChannel):
|
|
name = "dummy"
|
|
_sent: list[OutboundMessage]
|
|
|
|
def __init__(self, config, bus):
|
|
super().__init__(config, bus)
|
|
self._sent = []
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
async def stop(self) -> None:
|
|
return None
|
|
|
|
async def send(self, msg: OutboundMessage) -> None:
|
|
self._sent.append(msg)
|
|
|
|
|
|
def test_is_allowed_requires_exact_match() -> None:
|
|
channel = _DummyChannel(SimpleNamespace(allow_from=["allow@email.com"]), MessageBus())
|
|
|
|
assert channel.is_allowed("allow@email.com") is True
|
|
assert channel.is_allowed("attacker|allow@email.com") is False
|
|
|
|
|
|
def test_is_allowed_supports_dict_allow_from_alias() -> None:
|
|
channel = _DummyChannel({"allowFrom": ["alice"]}, MessageBus())
|
|
|
|
assert channel.is_allowed("alice") is True
|
|
|
|
|
|
def test_is_allowed_denies_empty_dict_allow_from() -> None:
|
|
channel = _DummyChannel({"allow_from": []}, MessageBus())
|
|
|
|
assert channel.is_allowed("alice") is False
|
|
|
|
|
|
def test_is_allowed_handles_none_allow_from() -> None:
|
|
channel = _DummyChannel({"allow_from": None}, MessageBus())
|
|
assert channel.is_allowed("alice") is False
|
|
|
|
channel2 = _DummyChannel({"allowFrom": None}, MessageBus())
|
|
assert channel2.is_allowed("alice") is False
|
|
|
|
|
|
def test_is_allowed_star_allows_all() -> None:
|
|
channel = _DummyChannel({"allowFrom": ["*"]}, MessageBus())
|
|
assert channel.is_allowed("anyone") is True
|
|
|
|
|
|
def test_is_allowed_pairing_fallback(monkeypatch) -> None:
|
|
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
|
monkeypatch.setattr(
|
|
"nanobot.channels.base.is_approved", lambda _ch, sid: sid == "paired"
|
|
)
|
|
assert channel.is_allowed("paired") is True
|
|
assert channel.is_allowed("unknown") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_message_dm_sends_pairing_code(monkeypatch) -> None:
|
|
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
|
monkeypatch.setattr(
|
|
"nanobot.channels.base.generate_code", lambda _ch, sid: "ABCD-EFGH"
|
|
)
|
|
|
|
await channel._handle_message(
|
|
sender_id="stranger", chat_id="chat1", content="hello", is_dm=True
|
|
)
|
|
|
|
assert len(channel._sent) == 1
|
|
msg = channel._sent[0]
|
|
assert "ABCD-EFGH" in msg.content
|
|
assert msg.metadata.get("_pairing_code") == "ABCD-EFGH"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_during_transient_store_failure_keeps_approvals(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
"""An unapproved DM while pairing.json is unreadable must not wipe approvals.
|
|
|
|
The pairing store treated a transient OSError like corruption and returned
|
|
an empty store; the DM pairing path then persisted that empty view,
|
|
erasing every approved sender.
|
|
"""
|
|
import builtins
|
|
from pathlib import Path
|
|
|
|
from nanobot.pairing import store
|
|
|
|
path = tmp_path / "pairing.json"
|
|
monkeypatch.setattr(store, "_store_path", lambda: path)
|
|
code = store.generate_code("dummy", "friend")
|
|
store.approve_code(code)
|
|
|
|
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
|
|
|
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)
|
|
|
|
with monkeypatch.context() as m:
|
|
m.setattr(builtins, "open", flaky_open)
|
|
await channel._handle_message(
|
|
sender_id="stranger", chat_id="chat1", content="hello", is_dm=True
|
|
)
|
|
|
|
assert channel._sent == []
|
|
assert store.is_approved("dummy", "friend") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_message_group_ignores_unknown() -> None:
|
|
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
|
|
|
await channel._handle_message(
|
|
sender_id="stranger", chat_id="chat1", content="hello", is_dm=False
|
|
)
|
|
|
|
assert channel._sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_message_uses_authorization_id_without_changing_sender() -> None:
|
|
bus = MessageBus()
|
|
channel = _DummyChannel({"allowFrom": ["group@g.us"]}, bus)
|
|
|
|
await channel._handle_message(
|
|
sender_id="member-lid",
|
|
authorization_id="group@g.us",
|
|
chat_id="group@g.us",
|
|
content="hello",
|
|
)
|
|
|
|
msg = await bus.consume_inbound()
|
|
assert msg.sender_id == "member-lid"
|
|
assert msg.chat_id == "group@g.us"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_message_rejects_when_authorization_id_is_not_allowed() -> None:
|
|
bus = MessageBus()
|
|
channel = _DummyChannel({"allowFrom": ["member-lid"]}, bus)
|
|
|
|
await channel._handle_message(
|
|
sender_id="member-lid",
|
|
authorization_id="other-group@g.us",
|
|
chat_id="other-group@g.us",
|
|
content="hello",
|
|
)
|
|
|
|
assert bus.inbound_size == 0
|
|
|