feat: add canary, tcping, archive, resolve plugins
canary: generate realistic fake credentials (token/aws/basic) for planting as canary tripwires. Per-channel state persistence. tcping: TCP connect latency probe through SOCKS5 proxy with min/avg/max reporting. Proxy-compatible alternative to traceroute. archive: save URLs to Wayback Machine via Save Page Now API, routed through SOCKS5 proxy. resolve: bulk DNS resolution (up to 10 hosts) via TCP DNS through SOCKS5 proxy with concurrent asyncio.gather. 83 new tests (1010 total), docs updated.
This commit is contained in:
150
tests/test_archive.py
Normal file
150
tests/test_archive.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Tests for the Wayback Machine archive plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from derp.irc import Message
|
||||
|
||||
# plugins/ is not a Python package -- load the module from file path
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"plugins.archive", Path(__file__).resolve().parent.parent / "plugins" / "archive.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.archive import _save_page, cmd_archive # noqa: E402
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self):
|
||||
self.replied: list[str] = []
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
|
||||
def _msg(text: str) -> Message:
|
||||
return Message(
|
||||
raw="", prefix="alice!~alice@host", nick="alice",
|
||||
command="PRIVMSG", params=["#test", text], tags={},
|
||||
)
|
||||
|
||||
|
||||
# -- _save_page --------------------------------------------------------------
|
||||
|
||||
class TestSavePage:
|
||||
def test_content_location_header(self):
|
||||
resp = MagicMock()
|
||||
resp.headers = {"Content-Location": "/web/20260220/https://example.com"}
|
||||
resp.read.return_value = b""
|
||||
resp.geturl.return_value = "https://web.archive.org/save/https://example.com"
|
||||
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
result = _save_page("https://example.com")
|
||||
|
||||
assert "url" in result
|
||||
assert "/web/20260220" in result["url"]
|
||||
|
||||
def test_final_url_redirect(self):
|
||||
resp = MagicMock()
|
||||
resp.headers = {}
|
||||
resp.read.return_value = b""
|
||||
resp.geturl.return_value = "https://web.archive.org/web/20260220/https://example.com"
|
||||
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
result = _save_page("https://example.com")
|
||||
|
||||
assert "url" in result
|
||||
assert "/web/20260220" in result["url"]
|
||||
|
||||
def test_fallback_url(self):
|
||||
resp = MagicMock()
|
||||
resp.headers = {}
|
||||
resp.read.return_value = b""
|
||||
resp.geturl.return_value = "https://web.archive.org/save/ok"
|
||||
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
result = _save_page("https://example.com")
|
||||
|
||||
assert "url" in result
|
||||
assert "/web/*/" in result["url"]
|
||||
|
||||
def test_rate_limit(self):
|
||||
exc = urllib.error.HTTPError(
|
||||
"url", 429, "Too Many Requests", {}, None,
|
||||
)
|
||||
with patch.object(_mod, "_urlopen", side_effect=exc):
|
||||
result = _save_page("https://example.com")
|
||||
|
||||
assert "error" in result
|
||||
assert "rate limit" in result["error"]
|
||||
|
||||
def test_origin_unreachable(self):
|
||||
exc = urllib.error.HTTPError(
|
||||
"url", 523, "Origin Unreachable", {}, None,
|
||||
)
|
||||
with patch.object(_mod, "_urlopen", side_effect=exc):
|
||||
result = _save_page("https://example.com")
|
||||
|
||||
assert "error" in result
|
||||
assert "unreachable" in result["error"]
|
||||
|
||||
def test_generic_http_error(self):
|
||||
exc = urllib.error.HTTPError(
|
||||
"url", 500, "Server Error", {}, None,
|
||||
)
|
||||
with patch.object(_mod, "_urlopen", side_effect=exc):
|
||||
result = _save_page("https://example.com")
|
||||
|
||||
assert "error" in result
|
||||
assert "500" in result["error"]
|
||||
|
||||
def test_timeout(self):
|
||||
with patch.object(_mod, "_urlopen", side_effect=TimeoutError("timed out")):
|
||||
result = _save_page("https://example.com")
|
||||
|
||||
assert "error" in result
|
||||
assert "timeout" in result["error"]
|
||||
|
||||
|
||||
# -- Command handler ---------------------------------------------------------
|
||||
|
||||
class TestCmdArchive:
|
||||
def test_no_args(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_archive(bot, _msg("!archive")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
def test_no_scheme(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_archive(bot, _msg("!archive example.com")))
|
||||
assert "http://" in bot.replied[0]
|
||||
|
||||
def test_success(self):
|
||||
bot = _FakeBot()
|
||||
result = {"url": "https://web.archive.org/web/20260220/https://example.com"}
|
||||
|
||||
with patch.object(_mod, "_save_page", return_value=result):
|
||||
asyncio.run(cmd_archive(bot, _msg("!archive https://example.com")))
|
||||
|
||||
assert len(bot.replied) == 2
|
||||
assert "Archiving" in bot.replied[0]
|
||||
assert "Archived:" in bot.replied[1]
|
||||
assert "/web/20260220" in bot.replied[1]
|
||||
|
||||
def test_error(self):
|
||||
bot = _FakeBot()
|
||||
result = {"error": "rate limited -- try again later"}
|
||||
|
||||
with patch.object(_mod, "_save_page", return_value=result):
|
||||
asyncio.run(cmd_archive(bot, _msg("!archive https://example.com")))
|
||||
|
||||
assert len(bot.replied) == 2
|
||||
assert "failed" in bot.replied[1].lower()
|
||||
assert "rate limit" in bot.replied[1]
|
||||
302
tests/test_canary.py
Normal file
302
tests/test_canary.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Tests for the canary token generator plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from derp.irc import Message
|
||||
|
||||
# plugins/ is not a Python package -- load the module from file path
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"plugins.canary", Path(__file__).resolve().parent.parent / "plugins" / "canary.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.canary import ( # noqa: E402
|
||||
_gen_aws,
|
||||
_gen_basic,
|
||||
_gen_token,
|
||||
_load,
|
||||
_save,
|
||||
cmd_canary,
|
||||
)
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
class _FakeState:
|
||||
"""In-memory stand-in for bot.state."""
|
||||
|
||||
def __init__(self):
|
||||
self._store: dict[str, dict[str, str]] = {}
|
||||
|
||||
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
|
||||
return self._store.get(plugin, {}).get(key, default)
|
||||
|
||||
def set(self, plugin: str, key: str, value: str) -> None:
|
||||
self._store.setdefault(plugin, {})[key] = value
|
||||
|
||||
def delete(self, plugin: str, key: str) -> bool:
|
||||
try:
|
||||
del self._store[plugin][key]
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def keys(self, plugin: str) -> list[str]:
|
||||
return sorted(self._store.get(plugin, {}).keys())
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
"""Minimal bot stand-in that captures sent/replied messages."""
|
||||
|
||||
def __init__(self, *, admin: bool = False):
|
||||
self.sent: list[tuple[str, str]] = []
|
||||
self.replied: list[str] = []
|
||||
self.state = _FakeState()
|
||||
self._admin = admin
|
||||
|
||||
async def send(self, target: str, text: str) -> None:
|
||||
self.sent.append((target, text))
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
def _is_admin(self, message) -> bool:
|
||||
return self._admin
|
||||
|
||||
|
||||
def _msg(text: str, nick: str = "alice", target: str = "#ops") -> Message:
|
||||
"""Create a channel PRIVMSG."""
|
||||
return Message(
|
||||
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||
command="PRIVMSG", params=[target, text], tags={},
|
||||
)
|
||||
|
||||
|
||||
def _pm(text: str, nick: str = "alice") -> Message:
|
||||
"""Create a private PRIVMSG."""
|
||||
return Message(
|
||||
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||
command="PRIVMSG", params=["botname", text], tags={},
|
||||
)
|
||||
|
||||
|
||||
# -- Token generators -------------------------------------------------------
|
||||
|
||||
class TestGenToken:
|
||||
def test_length(self):
|
||||
tok = _gen_token()
|
||||
assert len(tok) == 40
|
||||
|
||||
def test_hex(self):
|
||||
tok = _gen_token()
|
||||
int(tok, 16) # Should not raise
|
||||
|
||||
def test_unique(self):
|
||||
assert _gen_token() != _gen_token()
|
||||
|
||||
|
||||
class TestGenAws:
|
||||
def test_access_key_format(self):
|
||||
pair = _gen_aws()
|
||||
assert pair["access_key"].startswith("AKIA")
|
||||
assert len(pair["access_key"]) == 20
|
||||
|
||||
def test_secret_key_present(self):
|
||||
pair = _gen_aws()
|
||||
assert len(pair["secret_key"]) > 20
|
||||
|
||||
|
||||
class TestGenBasic:
|
||||
def test_user_format(self):
|
||||
pair = _gen_basic()
|
||||
assert pair["user"].startswith("svc")
|
||||
assert len(pair["user"]) == 8
|
||||
|
||||
def test_pass_present(self):
|
||||
pair = _gen_basic()
|
||||
assert len(pair["pass"]) > 10
|
||||
|
||||
|
||||
# -- State helpers -----------------------------------------------------------
|
||||
|
||||
class TestStateHelpers:
|
||||
def test_save_and_load(self):
|
||||
bot = _FakeBot()
|
||||
store = {"mykey": {"type": "token", "value": "abc", "created": "now"}}
|
||||
_save(bot, "#ops", store)
|
||||
loaded = _load(bot, "#ops")
|
||||
assert loaded == store
|
||||
|
||||
def test_load_empty(self):
|
||||
bot = _FakeBot()
|
||||
assert _load(bot, "#ops") == {}
|
||||
|
||||
def test_load_bad_json(self):
|
||||
bot = _FakeBot()
|
||||
bot.state.set("canary", "#ops", "not json{{{")
|
||||
assert _load(bot, "#ops") == {}
|
||||
|
||||
|
||||
# -- Command: gen ------------------------------------------------------------
|
||||
|
||||
class TestCmdGen:
|
||||
def test_gen_default_token(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen db-cred")))
|
||||
assert len(bot.replied) == 1
|
||||
assert "db-cred" in bot.replied[0]
|
||||
assert "token" in bot.replied[0]
|
||||
store = _load(bot, "#ops")
|
||||
assert "db-cred" in store
|
||||
assert store["db-cred"]["type"] == "token"
|
||||
assert len(store["db-cred"]["value"]) == 40
|
||||
|
||||
def test_gen_aws(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen aws staging-key")))
|
||||
assert "staging-key" in bot.replied[0]
|
||||
assert "AKIA" in bot.replied[0]
|
||||
store = _load(bot, "#ops")
|
||||
assert store["staging-key"]["type"] == "aws"
|
||||
|
||||
def test_gen_basic(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen basic svc-login")))
|
||||
assert "svc-login" in bot.replied[0]
|
||||
store = _load(bot, "#ops")
|
||||
assert store["svc-login"]["type"] == "basic"
|
||||
assert "user" in store["svc-login"]["value"]
|
||||
|
||||
def test_gen_requires_admin(self):
|
||||
bot = _FakeBot(admin=False)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen mytoken")))
|
||||
assert "Permission denied" in bot.replied[0]
|
||||
|
||||
def test_gen_requires_channel(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _pm("!canary gen mytoken")))
|
||||
assert "channel" in bot.replied[0].lower()
|
||||
|
||||
def test_gen_duplicate(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen dup-test")))
|
||||
bot.replied.clear()
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen dup-test")))
|
||||
assert "already exists" in bot.replied[0]
|
||||
|
||||
def test_gen_no_label(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
def test_gen_type_no_label(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen aws")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
def test_gen_invalid_label(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary gen b@d!")))
|
||||
assert "Label" in bot.replied[0]
|
||||
|
||||
|
||||
# -- Command: list -----------------------------------------------------------
|
||||
|
||||
class TestCmdList:
|
||||
def test_list_empty(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary list")))
|
||||
assert "No canaries" in bot.replied[0]
|
||||
|
||||
def test_list_populated(self):
|
||||
bot = _FakeBot()
|
||||
store = {
|
||||
"api-key": {"type": "token", "value": "abc", "created": "now"},
|
||||
"db-cred": {"type": "basic", "value": {"user": "x", "pass": "y"}, "created": "now"},
|
||||
}
|
||||
_save(bot, "#ops", store)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary list")))
|
||||
assert "api-key" in bot.replied[0]
|
||||
assert "db-cred" in bot.replied[0]
|
||||
|
||||
def test_list_requires_channel(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_canary(bot, _pm("!canary list")))
|
||||
assert "channel" in bot.replied[0].lower()
|
||||
|
||||
|
||||
# -- Command: info -----------------------------------------------------------
|
||||
|
||||
class TestCmdInfo:
|
||||
def test_info_exists(self):
|
||||
bot = _FakeBot()
|
||||
store = {"mykey": {"type": "token", "value": "a" * 40, "created": "2026-02-20T14:00:00"}}
|
||||
_save(bot, "#ops", store)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary info mykey")))
|
||||
assert "mykey" in bot.replied[0]
|
||||
assert "a" * 40 in bot.replied[0]
|
||||
|
||||
def test_info_missing(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary info nope")))
|
||||
assert "No canary" in bot.replied[0]
|
||||
|
||||
def test_info_no_label(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary info")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
def test_info_requires_channel(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_canary(bot, _pm("!canary info mykey")))
|
||||
assert "channel" in bot.replied[0].lower()
|
||||
|
||||
|
||||
# -- Command: del ------------------------------------------------------------
|
||||
|
||||
class TestCmdDel:
|
||||
def test_del_success(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
store = {"victim": {"type": "token", "value": "x", "created": "now"}}
|
||||
_save(bot, "#ops", store)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary del victim")))
|
||||
assert "Deleted" in bot.replied[0]
|
||||
assert _load(bot, "#ops") == {}
|
||||
|
||||
def test_del_nonexistent(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary del nope")))
|
||||
assert "No canary" in bot.replied[0]
|
||||
|
||||
def test_del_requires_admin(self):
|
||||
bot = _FakeBot(admin=False)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary del something")))
|
||||
assert "Permission denied" in bot.replied[0]
|
||||
|
||||
def test_del_requires_channel(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _pm("!canary del something")))
|
||||
assert "channel" in bot.replied[0].lower()
|
||||
|
||||
def test_del_no_label(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary del")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
|
||||
# -- Command: usage ----------------------------------------------------------
|
||||
|
||||
class TestCmdUsage:
|
||||
def test_no_args(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
def test_unknown_subcommand(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_canary(bot, _msg("!canary foobar")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
228
tests/test_resolve.py
Normal file
228
tests/test_resolve.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Tests for the bulk DNS resolve plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from derp.dns import encode_name
|
||||
from derp.irc import Message
|
||||
|
||||
# plugins/ is not a Python package -- load the module from file path
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"plugins.resolve", Path(__file__).resolve().parent.parent / "plugins" / "resolve.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.resolve import _query_tcp, _resolve_one, cmd_resolve # noqa: E402
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
def _make_a_response(ip_bytes: bytes = b"\x01\x02\x03\x04") -> bytes:
|
||||
"""Build a minimal A-record DNS response."""
|
||||
tid = b"\x00\x01"
|
||||
flags = struct.pack("!H", 0x8180)
|
||||
counts = struct.pack("!HHHH", 1, 1, 0, 0)
|
||||
qname = encode_name("example.com")
|
||||
question = qname + struct.pack("!HH", 1, 1)
|
||||
answer = qname + struct.pack("!HHIH", 1, 1, 300, len(ip_bytes)) + ip_bytes
|
||||
return tid + flags + counts + question + answer
|
||||
|
||||
|
||||
def _make_nxdomain_response() -> bytes:
|
||||
"""Build a minimal NXDOMAIN DNS response."""
|
||||
tid = b"\x00\x01"
|
||||
flags = struct.pack("!H", 0x8183) # rcode=3
|
||||
counts = struct.pack("!HHHH", 1, 0, 0, 0)
|
||||
qname = encode_name("nope.invalid")
|
||||
question = qname + struct.pack("!HH", 1, 1)
|
||||
return tid + flags + counts + question
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self):
|
||||
self.replied: list[str] = []
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
|
||||
def _msg(text: str) -> Message:
|
||||
return Message(
|
||||
raw="", prefix="alice!~alice@host", nick="alice",
|
||||
command="PRIVMSG", params=["#test", text], tags={},
|
||||
)
|
||||
|
||||
|
||||
# -- _query_tcp --------------------------------------------------------------
|
||||
|
||||
class TestQueryTcp:
|
||||
def test_a_record(self):
|
||||
response = _make_a_response()
|
||||
framed = struct.pack("!H", len(response)) + response
|
||||
|
||||
reader = AsyncMock()
|
||||
reader.readexactly = AsyncMock(side_effect=[framed[:2], framed[2:]])
|
||||
writer = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
|
||||
mock_open = AsyncMock(return_value=(reader, writer))
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
rcode, results = asyncio.run(_query_tcp("example.com", 1, "1.1.1.1"))
|
||||
|
||||
assert rcode == 0
|
||||
assert results == ["1.2.3.4"]
|
||||
|
||||
def test_nxdomain(self):
|
||||
response = _make_nxdomain_response()
|
||||
framed = struct.pack("!H", len(response)) + response
|
||||
|
||||
reader = AsyncMock()
|
||||
reader.readexactly = AsyncMock(side_effect=[framed[:2], framed[2:]])
|
||||
writer = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
|
||||
mock_open = AsyncMock(return_value=(reader, writer))
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
rcode, results = asyncio.run(_query_tcp("nope.invalid", 1, "1.1.1.1"))
|
||||
|
||||
assert rcode == 3
|
||||
assert results == []
|
||||
|
||||
|
||||
# -- _resolve_one ------------------------------------------------------------
|
||||
|
||||
class TestResolveOne:
|
||||
def test_success(self):
|
||||
mock_tcp = AsyncMock(return_value=(0, ["1.2.3.4"]))
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
result = asyncio.run(_resolve_one("example.com", "A", "1.1.1.1"))
|
||||
assert "example.com -> 1.2.3.4" == result
|
||||
|
||||
def test_nxdomain(self):
|
||||
mock_tcp = AsyncMock(return_value=(3, []))
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
result = asyncio.run(_resolve_one("bad.invalid", "A", "1.1.1.1"))
|
||||
assert "NXDOMAIN" in result
|
||||
|
||||
def test_timeout(self):
|
||||
mock_tcp = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
result = asyncio.run(_resolve_one("slow.example.com", "A", "1.1.1.1"))
|
||||
assert "timeout" in result
|
||||
|
||||
def test_error(self):
|
||||
mock_tcp = AsyncMock(side_effect=OSError("connection refused"))
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
result = asyncio.run(_resolve_one("down.example.com", "A", "1.1.1.1"))
|
||||
assert "error" in result
|
||||
|
||||
def test_ptr_auto(self):
|
||||
mock_tcp = AsyncMock(return_value=(0, ["dns.google"]))
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
result = asyncio.run(_resolve_one("8.8.8.8", "PTR", "1.1.1.1"))
|
||||
assert "dns.google" in result
|
||||
|
||||
def test_ptr_invalid_ip(self):
|
||||
result = asyncio.run(_resolve_one("not-an-ip", "PTR", "1.1.1.1"))
|
||||
assert "invalid IP" in result
|
||||
|
||||
def test_no_records(self):
|
||||
mock_tcp = AsyncMock(return_value=(0, []))
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
result = asyncio.run(_resolve_one("empty.example.com", "A", "1.1.1.1"))
|
||||
assert "no records" in result
|
||||
|
||||
def test_multiple_results(self):
|
||||
mock_tcp = AsyncMock(return_value=(0, ["1.1.1.1", "1.0.0.1"]))
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
result = asyncio.run(_resolve_one("multi.example.com", "A", "1.1.1.1"))
|
||||
assert "1.1.1.1, 1.0.0.1" in result
|
||||
|
||||
|
||||
# -- Command handler ---------------------------------------------------------
|
||||
|
||||
class TestCmdResolve:
|
||||
def test_no_args(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_resolve(bot, _msg("!resolve")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
def test_single_host(self):
|
||||
bot = _FakeBot()
|
||||
mock_tcp = AsyncMock(return_value=(0, ["93.184.216.34"]))
|
||||
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
asyncio.run(cmd_resolve(bot, _msg("!resolve example.com")))
|
||||
|
||||
assert len(bot.replied) == 1
|
||||
assert "example.com -> 93.184.216.34" in bot.replied[0]
|
||||
|
||||
def test_multiple_hosts(self):
|
||||
bot = _FakeBot()
|
||||
|
||||
async def fake_tcp(name, qtype, server, timeout=5.0):
|
||||
if "example" in name:
|
||||
return 0, ["93.184.216.34"]
|
||||
return 0, ["140.82.121.3"]
|
||||
|
||||
with patch.object(_mod, "_query_tcp", fake_tcp):
|
||||
asyncio.run(cmd_resolve(bot, _msg("!resolve example.com github.com")))
|
||||
|
||||
assert len(bot.replied) == 2
|
||||
assert "93.184.216.34" in bot.replied[0]
|
||||
assert "140.82.121.3" in bot.replied[1]
|
||||
|
||||
def test_explicit_type(self):
|
||||
bot = _FakeBot()
|
||||
mock_tcp = AsyncMock(return_value=(0, ["2606:2800:220:1:248:1893:25c8:1946"]))
|
||||
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
asyncio.run(cmd_resolve(bot, _msg("!resolve example.com AAAA")))
|
||||
|
||||
assert "2606:" in bot.replied[0]
|
||||
# Verify AAAA qtype (28) was used
|
||||
call_args = mock_tcp.call_args[0]
|
||||
assert call_args[1] == 28
|
||||
|
||||
def test_ip_auto_ptr(self):
|
||||
bot = _FakeBot()
|
||||
mock_tcp = AsyncMock(return_value=(0, ["dns.google"]))
|
||||
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
asyncio.run(cmd_resolve(bot, _msg("!resolve 8.8.8.8")))
|
||||
|
||||
assert "dns.google" in bot.replied[0]
|
||||
|
||||
def test_type_only_no_hosts(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_resolve(bot, _msg("!resolve AAAA")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
def test_nxdomain(self):
|
||||
bot = _FakeBot()
|
||||
mock_tcp = AsyncMock(return_value=(3, []))
|
||||
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
asyncio.run(cmd_resolve(bot, _msg("!resolve bad.invalid")))
|
||||
|
||||
assert "NXDOMAIN" in bot.replied[0]
|
||||
|
||||
def test_max_hosts(self):
|
||||
"""Hosts beyond MAX_HOSTS are truncated."""
|
||||
bot = _FakeBot()
|
||||
hosts = " ".join(f"h{i}.example.com" for i in range(15))
|
||||
mock_tcp = AsyncMock(return_value=(0, ["1.2.3.4"]))
|
||||
|
||||
with patch.object(_mod, "_query_tcp", mock_tcp):
|
||||
asyncio.run(cmd_resolve(bot, _msg(f"!resolve {hosts}")))
|
||||
|
||||
# 10 results + 1 truncation note
|
||||
assert len(bot.replied) == 11
|
||||
assert "showing first 10" in bot.replied[-1]
|
||||
203
tests/test_tcping.py
Normal file
203
tests/test_tcping.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Tests for the TCP ping plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from derp.irc import Message
|
||||
|
||||
# plugins/ is not a Python package -- load the module from file path
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"plugins.tcping", Path(__file__).resolve().parent.parent / "plugins" / "tcping.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.tcping import ( # noqa: E402
|
||||
_is_internal,
|
||||
_probe,
|
||||
_validate_host,
|
||||
cmd_tcping,
|
||||
)
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self):
|
||||
self.replied: list[str] = []
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
|
||||
def _msg(text: str) -> Message:
|
||||
return Message(
|
||||
raw="", prefix="alice!~alice@host", nick="alice",
|
||||
command="PRIVMSG", params=["#test", text], tags={},
|
||||
)
|
||||
|
||||
|
||||
# -- Validation --------------------------------------------------------------
|
||||
|
||||
class TestValidateHost:
|
||||
def test_valid_ip(self):
|
||||
assert _validate_host("93.184.216.34") is True
|
||||
|
||||
def test_valid_domain(self):
|
||||
assert _validate_host("example.com") is True
|
||||
|
||||
def test_invalid_no_dot(self):
|
||||
assert _validate_host("localhost") is False
|
||||
|
||||
def test_invalid_chars(self):
|
||||
assert _validate_host("bad host!") is False
|
||||
|
||||
|
||||
class TestIsInternal:
|
||||
def test_private(self):
|
||||
assert _is_internal("192.168.1.1") is True
|
||||
|
||||
def test_loopback(self):
|
||||
assert _is_internal("127.0.0.1") is True
|
||||
|
||||
def test_public(self):
|
||||
assert _is_internal("8.8.8.8") is False
|
||||
|
||||
def test_domain(self):
|
||||
assert _is_internal("example.com") is False
|
||||
|
||||
|
||||
# -- Probe -------------------------------------------------------------------
|
||||
|
||||
class TestProbe:
|
||||
def test_success(self):
|
||||
writer = MagicMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
mock_open = AsyncMock(return_value=(MagicMock(), writer))
|
||||
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
rtt = asyncio.run(_probe("example.com", 443, 5.0))
|
||||
|
||||
assert rtt is not None
|
||||
assert rtt >= 0
|
||||
writer.close.assert_called_once()
|
||||
|
||||
def test_timeout(self):
|
||||
mock_open = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
rtt = asyncio.run(_probe("example.com", 443, 0.1))
|
||||
|
||||
assert rtt is None
|
||||
|
||||
def test_connection_error(self):
|
||||
mock_open = AsyncMock(side_effect=OSError("refused"))
|
||||
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
rtt = asyncio.run(_probe("example.com", 443, 5.0))
|
||||
|
||||
assert rtt is None
|
||||
|
||||
|
||||
# -- Command -----------------------------------------------------------------
|
||||
|
||||
class TestCmdTcping:
|
||||
def test_no_args(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping")))
|
||||
assert "Usage" in bot.replied[0]
|
||||
|
||||
def test_invalid_host(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping notahost")))
|
||||
assert "Invalid host" in bot.replied[0]
|
||||
|
||||
def test_internal_host(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping 192.168.1.1")))
|
||||
assert "internal" in bot.replied[0].lower()
|
||||
|
||||
def test_invalid_port(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping example.com 99999")))
|
||||
assert "Invalid port" in bot.replied[0]
|
||||
|
||||
def test_invalid_port_string(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping example.com abc")))
|
||||
assert "Invalid port" in bot.replied[0]
|
||||
|
||||
def test_success_default(self):
|
||||
"""Default 3 probes, all succeed."""
|
||||
bot = _FakeBot()
|
||||
writer = MagicMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
mock_open = AsyncMock(return_value=(MagicMock(), writer))
|
||||
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping example.com")))
|
||||
|
||||
assert len(bot.replied) == 1
|
||||
reply = bot.replied[0]
|
||||
assert "example.com:443" in reply
|
||||
assert "3 probes" in reply
|
||||
assert "min/avg/max" in reply
|
||||
|
||||
def test_custom_port_and_count(self):
|
||||
bot = _FakeBot()
|
||||
writer = MagicMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
mock_open = AsyncMock(return_value=(MagicMock(), writer))
|
||||
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping example.com 22 5")))
|
||||
|
||||
reply = bot.replied[0]
|
||||
assert "example.com:22" in reply
|
||||
assert "5 probes" in reply
|
||||
|
||||
def test_all_timeout(self):
|
||||
bot = _FakeBot()
|
||||
mock_open = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping example.com")))
|
||||
|
||||
assert "timed out" in bot.replied[0]
|
||||
|
||||
def test_count_clamped(self):
|
||||
"""Count > MAX_COUNT gets clamped."""
|
||||
bot = _FakeBot()
|
||||
writer = MagicMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
mock_open = AsyncMock(return_value=(MagicMock(), writer))
|
||||
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping example.com 443 99")))
|
||||
|
||||
# MAX_COUNT is 10
|
||||
assert "10 probes" in bot.replied[0]
|
||||
|
||||
def test_partial_timeout(self):
|
||||
"""Some probes succeed, some fail."""
|
||||
bot = _FakeBot()
|
||||
call_count = 0
|
||||
writer = MagicMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
|
||||
async def mock_open(host, port, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 2:
|
||||
raise asyncio.TimeoutError()
|
||||
return (MagicMock(), writer)
|
||||
|
||||
with patch.object(_mod, "_open_connection", mock_open):
|
||||
asyncio.run(cmd_tcping(bot, _msg("!tcping example.com 443 3")))
|
||||
|
||||
reply = bot.replied[0]
|
||||
assert "timeout" in reply
|
||||
assert "min/avg/max" in reply
|
||||
Reference in New Issue
Block a user