exhaustive testing of Modern's WHOIS spec (#104)

* Add testWhoisNumerics, to check Modern exhaustively covers known numerics

* ircu2: Workaround for server name in testWhoisNumerics.

* testWhoisUser: Work around ircu2 restrictions on nick and username

* testWhoisNumerics: Add variant with authenticated user

* testWhoisNumerics: Add support for RPL_AWAY and RPL_WHOISSPECIAL

* testWhoisNumerics: Add variant where the WHOIS sender opers up first

* testWhoisUser: Also test with targets

* inspircd: Fix oper configuration

* Fix RPL_WHOISACTUALLY matching for Unreal.
This commit is contained in:
Val Lorentz 2021-08-29 16:38:38 +02:00 committed by GitHub
parent 03a401f911
commit 23c7c1642b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 302 additions and 24 deletions

View File

@ -23,6 +23,7 @@ BAHAMUT_SELECTORS := \
# testQuitErrors is very flaky
# AccountTagTestCase.testInvite fails because https://github.com/solanum-ircd/solanum/issues/166
# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker.
# testWhoisNumerics[oper] fails because charybdis uses RPL_WHOISSPECIAL instead of RPL_WHOISOPERATOR
CHARYBDIS_SELECTORS := \
not Ergo \
and not deprecated \
@ -30,6 +31,7 @@ CHARYBDIS_SELECTORS := \
and not testQuitErrors \
and not testKickDefaultComment \
and not (AccountTagTestCase and testInvite) \
and not (testWhoisNumerics and oper) \
$(EXTRA_SELECTORS)
ERGO_SELECTORS := \

View File

@ -750,29 +750,27 @@ class OptionalityHelper(Generic[TController]):
@staticmethod
def skipUnlessHasMechanism(
mech: str,
) -> Callable[[Callable[[_TSelf], _TReturn]], Callable[[_TSelf], _TReturn]]:
) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]:
# Just a function returning a function that takes functions and
# returns functions, nothing to see here.
# If Python didn't have such an awful syntax for callables, it would be:
# str -> ((TSelf -> TReturn) -> (TSelf -> TReturn))
def decorator(f: Callable[[_TSelf], _TReturn]) -> Callable[[_TSelf], _TReturn]:
def decorator(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]:
@functools.wraps(f)
def newf(self: _TSelf) -> _TReturn:
def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn:
self.checkMechanismSupport(mech)
return f(self)
return f(self, *args, **kwargs)
return newf
return decorator
@staticmethod
def skipUnlessHasSasl(
f: Callable[[_TSelf], _TReturn]
) -> Callable[[_TSelf], _TReturn]:
def skipUnlessHasSasl(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]:
@functools.wraps(f)
def newf(self: _TSelf) -> _TReturn:
def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn:
self.checkSaslSupport()
return f(self)
return f(self, *args, **kwargs)
return newf

View File

@ -69,6 +69,14 @@ connect {{
cpasswd password;
class services;
}};
oper {{
name operuser;
host *@*;
passwd operpassword;
access *Aa;
class users;
}};
"""

View File

@ -52,6 +52,21 @@ connect "services.example.org" {{
service {{
name = "services.example.org";
}};
privset "omnioper" {{
privs = oper:general, oper:privs, oper:testline, oper:kill, oper:operwall, oper:message,
oper:routing, oper:kline, oper:unkline, oper:xline,
oper:resv, oper:cmodes, oper:mass_notice, oper:wallops,
oper:remoteban,
usermode:servnotice, auspex:oper, auspex:hostname, auspex:umodes, auspex:cmodes,
oper:admin, oper:die, oper:rehash, oper:spy, oper:grant;
}};
operator "operuser" {{
user = "*@*";
password = "operpassword";
privset = "omnioper";
flags = ~encrypted;
}};
"""

View File

@ -11,8 +11,6 @@ from irctest.basecontrollers import (
)
from irctest.cases import BaseServerTestCase
OPER_PWD = "frenchfries"
BASE_CONFIG = {
"network": {"name": "ErgoTest"},
"server": {
@ -110,11 +108,11 @@ BASE_CONFIG = {
}
},
"opers": {
"root": {
"operuser": {
"class": "server-admin",
"whois-line": "is a server admin",
# OPER_PWD
"password": "$2a$04$3GzUZB5JapaAbwn7sogpOu9NSiLOgnozVllm2e96LiNPrm61ZsZSq",
# "operpassword"
"password": "$2a$04$bKb6k5A6yuFA2wx.iJtxcuT2dojHQAjHd5ZPK/I2sjJml7p4spxjG",
}
},
}
@ -291,7 +289,7 @@ class ErgoController(BaseServerController, DirectoryBasedController):
self._write_config()
client = "operator_for_rehash"
case.connectClient(nick=client, name=client)
case.sendLine(client, "OPER root %s" % (OPER_PWD,))
case.sendLine(client, "OPER operuser operpassword")
case.sendLine(client, "REHASH")
case.getMessages(client)
case.sendLine(client, "QUIT")

View File

@ -55,6 +55,16 @@ auth {{
flags = exceed_limit;
{password_field}
}};
operator {{
name = "operuser";
user = "*@*";
password = "operpassword";
encrypted = no;
umodes = locops, servnotice, wallop;
flags = admin, connect, connect:remote, die, globops, kill, kill:remote,
kline, module, rehash, restart, set, unkline, unxline, xline;
}};
"""

View File

@ -19,6 +19,21 @@ TEMPLATE_CONFIG = """
timeout="10" # So tests don't hang too long
{password_field}>
<class
name="ServerOperators"
privs="channels/auspex users/auspex channels/auspex servers/auspex"
>
<type
name="NetAdmin"
classes="ServerOperators"
>
<oper name="operuser"
password="operpassword"
host="*@*"
type="NetAdmin"
class="ServerOperators"
>
<options casemapping="ascii">
# Disable 'NOTICE #chan :*** foo invited bar into the channel-

View File

@ -23,6 +23,9 @@ Y:10:90::100:512000:100.100:100.100:
# I:<TARGET Host Addr>:<Password>:<TARGET Hosts NAME>:<Port>:<Class>:<Flags>:
I::{password_field}:::10::
# O:<TARGET Host NAME>:<Password>:<Nickname>:<Port>:<Class>:<Flags>:
O:*:operpassword:operuser::::
"""

View File

@ -33,8 +33,19 @@ Client {{
{password_field}
}};
Operator {{
local = no;
host = "*@*";
password = "$PLAIN$operpassword";
name = "operuser";
class = "Client";
}};
features {{
"PPATH" = "{pidfile}";
# workaround for whois tests, checking the server name
"HIS_SERVERNAME" = "My.Little.Server";
}};
"""

View File

@ -60,6 +60,16 @@ auth {{
flags = exceed_limit;
{password_field}
}};
operator {{
name = "operuser";
user = "*@*";
password = "operpassword";
encrypted = no;
umodes = locops, servnotice, wallop;
flags = admin, connect, connect:remote, die, globops, kill, kill:remote,
kline, module, rehash, restart, set, unkline, unxline, xline;
}};
"""

View File

@ -33,6 +33,14 @@ Client {{
{password_field}
}};
Operator {{
local = no;
host = "*@*";
password = "$PLAIN$operpassword";
name = "operuser";
class = "Client";
}};
features {{
"PPATH" = "{pidfile}";

View File

@ -11,6 +11,7 @@ from irctest.irc_utils.junkdrawer import find_hostname_and_port
TEMPLATE_CONFIG = """
include "modules.default.conf";
include "operclass.default.conf";
me {{
name "My.Little.Server";
@ -99,6 +100,13 @@ tld {{
botmotd "{empty_file}";
rules "{empty_file}";
}}
oper "operuser" {{
password = "operpassword";
mask *;
class clients;
operclass netadmin;
}}
"""

View File

@ -56,6 +56,7 @@ RPL_USERHOST = "302"
RPL_ISON = "303"
RPL_UNAWAY = "305"
RPL_NOWAWAY = "306"
RPL_WHOISREGNICK = "307"
RPL_WHOISUSER = "311"
RPL_WHOISSERVER = "312"
RPL_WHOISOPERATOR = "313"
@ -64,6 +65,7 @@ RPL_ENDOFWHO = "315"
RPL_WHOISIDLE = "317"
RPL_ENDOFWHOIS = "318"
RPL_WHOISCHANNELS = "319"
RPL_WHOISSPECIAL = "320"
RPL_LIST = "322"
RPL_LISTEND = "323"
RPL_CHANNELMODEIS = "324"
@ -95,6 +97,8 @@ RPL_MOTD = "372"
RPL_ENDOFINFO = "374"
RPL_MOTDSTART = "375"
RPL_ENDOFMOTD = "376"
RPL_WHOISHOST = "378"
RPL_WHOISMODES = "379"
RPL_YOUREOPER = "381"
RPL_REHASHING = "382"
RPL_YOURESERVICE = "383"

View File

@ -1,15 +1,175 @@
import pytest
from irctest import cases
from irctest.numerics import RPL_WHOISCHANNELS, RPL_WHOISUSER
from irctest.numerics import (
RPL_AWAY,
RPL_ENDOFWHOIS,
RPL_WHOISACCOUNT,
RPL_WHOISACTUALLY,
RPL_WHOISCHANNELS,
RPL_WHOISHOST,
RPL_WHOISIDLE,
RPL_WHOISMODES,
RPL_WHOISOPERATOR,
RPL_WHOISREGNICK,
RPL_WHOISSECURE,
RPL_WHOISSERVER,
RPL_WHOISSPECIAL,
RPL_WHOISUSER,
RPL_YOUREOPER,
)
from irctest.patma import ANYSTR, StrRe
@cases.mark_services
class WhoisTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
class _WhoisTestMixin(cases.BaseServerTestCase):
def _testWhoisNumerics(self, authenticate, away, oper):
if authenticate:
self.connectClient("nick1")
self.controller.registerUser(self, "val", "sesame")
self.connectClient(
"nick2", account="val", password="sesame", capabilities=["sasl"]
)
else:
self.connectClient("nick1")
self.connectClient("nick2")
self.sendLine(2, "JOIN #chan1")
self.sendLine(2, "JOIN #chan2")
if away:
self.sendLine(2, "AWAY :I'm on a break")
self.getMessages(2)
self.getMessages(1)
if oper:
self.sendLine(1, "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages(1)],
fail_msg="OPER failed",
)
self.sendLine(1, "WHOIS nick2")
messages = []
for _ in range(10):
messages.extend(self.getMessages(1))
if RPL_ENDOFWHOIS in (m.command for m in messages):
break
last_message = messages.pop()
self.assertMessageMatch(
last_message,
command=RPL_ENDOFWHOIS,
params=["nick1", "nick2", ANYSTR],
fail_msg=f"Last message was not RPL_ENDOFWHOIS ({RPL_ENDOFWHOIS})",
)
unexpected_messages = []
# Straight from the Modern spec
for m in messages:
if m.command == RPL_AWAY and away:
self.assertMessageMatch(m, params=["nick1", "nick2", "I'm on a break"])
elif m.command == RPL_WHOISREGNICK and authenticate:
self.assertMessageMatch(m, params=["nick1", "nick2", ANYSTR])
elif m.command == RPL_WHOISUSER:
self.assertMessageMatch(
m, params=["nick1", "nick2", ANYSTR, ANYSTR, "*", ANYSTR]
)
elif m.command == RPL_WHOISCHANNELS:
self.assertMessageMatch(
m,
params=[
"nick1",
"nick2",
StrRe("(@#chan1 @#chan2|@#chan2 @#chan1)"),
],
)
elif m.command == RPL_WHOISSPECIAL:
# Technically allowed, but it's a bad style to use this without
# explicit configuration by the operators.
assert False, "RPL_WHOISSPECIAL in use with default configuration"
elif m.command == RPL_WHOISSERVER:
self.assertMessageMatch(
m, params=["nick1", "nick2", "My.Little.Server", ANYSTR]
)
elif m.command == RPL_WHOISOPERATOR:
# TODO: unlikely to ever send this, we should oper up nick2 first
self.assertMessageMatch(m, params=["nick1", "nick2", ANYSTR])
elif m.command == RPL_WHOISIDLE:
self.assertMessageMatch(
m,
params=["nick1", "nick2", StrRe("[0-9]+"), StrRe("[0-9]+"), ANYSTR],
)
elif m.command == RPL_WHOISACCOUNT and authenticate:
self.assertMessageMatch(m, params=["nick1", "nick2", "val", ANYSTR])
elif m.command == RPL_WHOISACTUALLY:
host_re = "[0-9a-z_:.-]+"
if len(m.params) == 4:
# Most common
self.assertMessageMatch(
m,
params=[
"nick1",
"nick2",
StrRe(host_re),
ANYSTR,
],
)
elif len(m.params) == 5:
# eg. Hybrid, Unreal
self.assertMessageMatch(
m,
params=[
"nick1",
"nick2",
StrRe(r"(~?username|\*)@" + host_re),
StrRe(host_re),
ANYSTR,
],
)
elif len(m.params) == 3:
# eg. Plexus4
self.assertMessageMatch(
m,
params=[
"nick1",
"nick2",
ANYSTR,
],
)
else:
assert (
False
), f"Unexpected number of params for RPL_WHOISACTUALLY: {m.params}"
elif m.command == RPL_WHOISHOST:
self.assertMessageMatch(m, params=["nick1", "nick2", ANYSTR])
elif m.command == RPL_WHOISMODES:
self.assertMessageMatch(m, params=["nick1", "nick2", ANYSTR])
elif m.command == RPL_WHOISSECURE:
# TODO: unlikely to ever send this, we should oper up nick2 first
self.assertMessageMatch(m, params=["nick1", "nick2", ANYSTR])
else:
unexpected_messages.append(m)
self.assertEqual(
unexpected_messages, [], fail_msg="Unexpected numeric messages: {got}"
)
class WhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase, cases.OptionalityHelper):
@pytest.mark.parametrize(
"server",
["", "My.Little.Server", "myCoolNick"],
ids=["no-target", "target_server", "target-nick"],
)
@cases.mark_specifications("RFC2812")
def testWhoisUser(self):
def testWhoisUser(self, server):
"""Test basic WHOIS behavior"""
nick = "myCoolNickname"
username = "myUsernam" # may be truncated if longer than this
realname = "My Real Name"
nick = "myCoolNick"
username = "myusernam" # may be truncated if longer than this
realname = "My User Name"
self.addClient()
self.sendLine(1, f"NICK {nick}")
self.sendLine(1, f"USER {username} 0 * :{realname}")
@ -17,7 +177,7 @@ class WhoisTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.connectClient("otherNickname")
self.getMessages(2)
self.sendLine(2, "WHOIS mycoolnickname")
self.sendLine(2, f"WHOIS {server} mycoolnick")
messages = self.getMessages(2)
whois_user = messages[0]
self.assertEqual(whois_user.command, RPL_WHOISUSER)
@ -30,6 +190,33 @@ class WhoisTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
)
self.assertEqual(whois_user.params[5], realname)
@pytest.mark.parametrize(
"away,oper",
[(False, False), (True, False), (False, True)],
ids=["normal", "away", "oper"],
)
@cases.mark_specifications("Modern")
def testWhoisNumerics(self, away, oper):
"""Tests all numerics are in the exhaustive list defined in the Modern spec.
TBD modern PR"""
self._testWhoisNumerics(authenticate=False, away=away, oper=oper)
@cases.mark_services
class ServicesWhoisTestCase(
_WhoisTestMixin, cases.BaseServerTestCase, cases.OptionalityHelper
):
@pytest.mark.parametrize("oper", [False, True], ids=["normal", "oper"])
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
@cases.mark_specifications("Modern")
def testWhoisNumerics(self, oper):
"""Tests all numerics are in the exhaustive list defined in the Modern spec,
on an authenticated user.
TBD modern PR"""
self._testWhoisNumerics(oper=oper, authenticate=True, away=False)
@cases.mark_specifications("Ergo")
def testInvisibleWhois(self):
"""Test interaction between MODE +i and RPL_WHOISCHANNELS."""

View File

@ -1,6 +1,7 @@
[flake8]
# E203: whitespaces before ':' <https://github.com/psf/black/issues/315>
# E231: missing whitespace after ','
# E501: line too long
# W503: line break before binary operator <https://github.com/psf/black/issues/52>
ignore = E203,E231,W503
ignore = E203,E231,E501,W503
max-line-length = 88