code-review fixes: fsync, entropy, is_dm propagation, tests
- Add os.fsync with Windows-compatible directory flush in pairing store - Increase pairing code length from 6 -> 8 characters for higher entropy - Remove SystemExit on empty allowFrom; empty list now defers to pairing - Update is_allowed docstring to document pairing fallback semantics - Propagate is_dm to Matrix (direct rooms) and Slack (im channels) - Slack _is_allowed now checks pairing store for DM allowlist mode - Fix /pairing revoke to accept optional channel argument - Move inline import time to module top-level - Add WebSocket comment explaining is_dm=True assumption - Add comprehensive tests for store and BaseChannel pairing integration - Fix existing tests that expected empty allowFrom to hard-exit Refs #3774
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
@@ -7,6 +9,11 @@ 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
|
||||
@@ -15,7 +22,7 @@ class _DummyChannel(BaseChannel):
|
||||
return None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
return None
|
||||
self._sent.append(msg)
|
||||
|
||||
|
||||
def test_is_allowed_requires_exact_match() -> None:
|
||||
@@ -35,3 +42,94 @@ 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_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_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_pairing_command_list(monkeypatch) -> None:
|
||||
channel = _DummyChannel({"allowFrom": ["owner"]}, MessageBus())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.base.list_pending",
|
||||
lambda: [
|
||||
{
|
||||
"code": "ABCD-EFGH",
|
||||
"channel": "dummy",
|
||||
"sender_id": "123",
|
||||
"expires_at": 9999999999,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
await channel._handle_pairing_command("owner", "chat1", "/pairing list")
|
||||
|
||||
assert len(channel._sent) == 1
|
||||
assert "ABCD-EFGH" in channel._sent[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_pairing_command_approve(monkeypatch) -> None:
|
||||
channel = _DummyChannel({"allowFrom": ["owner"]}, MessageBus())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.base.approve_code",
|
||||
lambda code: ("dummy", "123") if code == "ABCD-EFGH" else None,
|
||||
)
|
||||
|
||||
await channel._handle_pairing_command("owner", "chat1", "/pairing approve ABCD-EFGH")
|
||||
|
||||
assert len(channel._sent) == 1
|
||||
assert "Approved" in channel._sent[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_pairing_command_revoke(monkeypatch) -> None:
|
||||
channel = _DummyChannel({"allowFrom": ["owner"]}, MessageBus())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.base.revoke",
|
||||
lambda ch, sid: sid == "123",
|
||||
)
|
||||
|
||||
await channel._handle_pairing_command("owner", "chat1", "/pairing revoke 123")
|
||||
|
||||
assert len(channel._sent) == 1
|
||||
assert "Revoked" in channel._sent[0].content
|
||||
|
||||
@@ -961,8 +961,8 @@ class _StartableChannel(BaseChannel):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_allow_from_raises_on_empty_list():
|
||||
"""_validate_allow_from should raise SystemExit when allow_from is empty list."""
|
||||
async def test_validate_allow_from_allows_empty_list():
|
||||
"""Empty allow_from is valid now — pairing store handles unapproved senders."""
|
||||
fake_config = SimpleNamespace(
|
||||
channels=ChannelsConfig(),
|
||||
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||
@@ -973,10 +973,8 @@ async def test_validate_allow_from_raises_on_empty_list():
|
||||
mgr.channels = {"test": _ChannelWithAllowFrom(fake_config, None, [])}
|
||||
mgr._dispatch_task = None
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
mgr._validate_allow_from()
|
||||
|
||||
assert "empty allowFrom" in str(exc_info.value)
|
||||
# Should not raise — empty list defers to pairing store
|
||||
mgr._validate_allow_from()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -997,8 +995,8 @@ async def test_validate_allow_from_passes_with_asterisk():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_allow_from_raises_on_empty_dict_allow_from():
|
||||
"""_validate_allow_from should reject empty dict-backed allow_from lists."""
|
||||
async def test_validate_allow_from_allows_empty_dict_allow_from():
|
||||
"""Empty dict-backed allow_from is valid — pairing store handles approval."""
|
||||
fake_config = SimpleNamespace(
|
||||
channels=ChannelsConfig(),
|
||||
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||
@@ -1009,10 +1007,7 @@ async def test_validate_allow_from_raises_on_empty_dict_allow_from():
|
||||
mgr.channels = {"test": _ChannelWithAllowFrom({"enabled": True}, None, [])}
|
||||
mgr._dispatch_task = None
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
mgr._validate_allow_from()
|
||||
|
||||
assert "empty allowFrom" in str(exc_info.value)
|
||||
mgr._validate_allow_from()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.pairing import store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_store(tmp_path, monkeypatch):
|
||||
path = tmp_path / "pairing.json"
|
||||
monkeypatch.setattr(store, "_store_path", lambda: path)
|
||||
|
||||
|
||||
class TestGenerateCode:
|
||||
def test_format(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
assert len(code) == 9 # 4 + 1 + 4
|
||||
assert code[4] == "-"
|
||||
assert code.replace("-", "").isalnum()
|
||||
assert code.replace("-", "").isupper()
|
||||
|
||||
def test_uniqueness(self) -> None:
|
||||
codes = {store.generate_code("telegram", str(i)) for i in range(20)}
|
||||
assert len(codes) == 20
|
||||
|
||||
def test_ttl_expiration(self) -> None:
|
||||
code = store.generate_code("telegram", "123", ttl=1)
|
||||
assert store.approve_code(code) is not None
|
||||
|
||||
code2 = store.generate_code("telegram", "456", ttl=0)
|
||||
time.sleep(0.1)
|
||||
assert store.approve_code(code2) is None
|
||||
|
||||
|
||||
class TestApproveDeny:
|
||||
def test_approve_moves_to_approved(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
assert store.is_approved("telegram", "123") is False
|
||||
|
||||
result = store.approve_code(code)
|
||||
assert result == ("telegram", "123")
|
||||
assert store.is_approved("telegram", "123") is True
|
||||
assert store.get_approved("telegram") == ["123"]
|
||||
|
||||
def test_deny_removes_pending(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
assert store.deny_code(code) is True
|
||||
assert store.approve_code(code) is None
|
||||
|
||||
def test_deny_unknown_returns_false(self) -> None:
|
||||
assert store.deny_code("UNKNOWN") is False
|
||||
|
||||
def test_approve_expired_returns_none(self) -> None:
|
||||
code = store.generate_code("telegram", "123", ttl=0)
|
||||
time.sleep(0.1)
|
||||
assert store.approve_code(code) is None
|
||||
|
||||
|
||||
class TestRevoke:
|
||||
def test_revoke_removes_sender(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
store.approve_code(code)
|
||||
assert store.is_approved("telegram", "123") is True
|
||||
|
||||
assert store.revoke("telegram", "123") is True
|
||||
assert store.is_approved("telegram", "123") is False
|
||||
assert store.get_approved("telegram") == []
|
||||
|
||||
def test_revoke_unknown_returns_false(self) -> None:
|
||||
assert store.revoke("telegram", "999") is False
|
||||
|
||||
|
||||
class TestListPending:
|
||||
def test_empty(self) -> None:
|
||||
assert store.list_pending() == []
|
||||
|
||||
def test_shows_pending(self) -> None:
|
||||
store.generate_code("telegram", "123")
|
||||
store.generate_code("discord", "456")
|
||||
pending = store.list_pending()
|
||||
assert len(pending) == 2
|
||||
channels = {p["channel"] for p in pending}
|
||||
assert channels == {"telegram", "discord"}
|
||||
|
||||
def test_expired_not_listed(self) -> None:
|
||||
store.generate_code("telegram", "123", ttl=0)
|
||||
time.sleep(0.1)
|
||||
assert store.list_pending() == []
|
||||
|
||||
|
||||
class TestStoreDurability:
|
||||
def test_corruption_recovery(self, tmp_path, monkeypatch) -> None:
|
||||
path = tmp_path / "pairing.json"
|
||||
path.write_text("not json{", encoding="utf-8")
|
||||
monkeypatch.setattr(store, "_store_path", lambda: path)
|
||||
|
||||
# Should recover gracefully and act as empty store
|
||||
assert store.list_pending() == []
|
||||
assert store.is_approved("telegram", "123") is False
|
||||
Reference in New Issue
Block a user