diff --git a/irctest/server_tests/away.py b/irctest/server_tests/away.py index 36841d6f..585be01b 100644 --- a/irctest/server_tests/away.py +++ b/irctest/server_tests/away.py @@ -3,7 +3,7 @@ `Modern `__) """ -from irctest import cases +from irctest import cases, runner from irctest.numerics import ( RPL_AWAY, RPL_NOWAWAY, @@ -181,3 +181,45 @@ def testAwayEmptyMessage(self): replies = self.getMessages("qux") self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies]) self.assertNotIn(RPL_AWAY, [msg.command for msg in replies]) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("AWAYLEN") + def testAwaylen(self): + """ + "AWAYLEN= + The AWAYLEN parameter indicates the maximum length for the of an AWAY command." + -- https://modern.ircdocs.horse/#awaylen-parameter + """ + self.connectClient("foo") + + if "AWAYLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("AWAYLEN") + + awaylen = int(self.server_support["AWAYLEN"]) + + # Set away message at exactly the limit + valid_away = "a" * awaylen + self.sendLine(1, f"AWAY :{valid_away}") + self.assertMessageMatch( + self.getMessage(1), command="306", params=["foo", ANYSTR] + ) # RPL_NOWAWAY + + # Set away message longer than the limit + long_away = "b" * (awaylen + 50) + self.sendLine(1, f"AWAY :{long_away}") + self.getMessages(1) + + # Check the away message + self.connectClient("bar") + self.sendLine(2, "WHOIS foo") + msgs = self.getMessages(2) + + away_msgs = [m for m in msgs if m.command == "301"] + self.assertMessageMatch( + away_msgs[0], command="301", params=["bar", "foo", ANYSTR] + ) + self.assertLessEqual( + len(away_msgs[0].params[2]), + awaylen, + f"Server sent away message longer than AWAYLEN {awaylen}", + ) diff --git a/irctest/server_tests/isupport_limits.py b/irctest/server_tests/isupport_limits.py new file mode 100644 index 00000000..822e0bec --- /dev/null +++ b/irctest/server_tests/isupport_limits.py @@ -0,0 +1,137 @@ +""" +Tests for ISUPPORT limit enforcement (`Modern +`__) +""" + +import pytest + +from irctest import cases, runner +from irctest.numerics import ( + ERR_BADCHANMASK, + ERR_ERRONEUSNICKNAME, + ERR_FORBIDDENCHANNEL, + ERR_NOSUCHCHANNEL, + ERR_TOOMANYCHANNELS, +) +from irctest.patma import ANYSTR, Either + +ERR_BADCHANNAME = "479" # Hybrid only, and conflicts with others + + +class IsupportLimitTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("Modern") + @cases.mark_isupport("CHANLIMIT") + @pytest.mark.parametrize("prefix", ["#", "&"]) + def testChanlimit(self, prefix): + """ + "CHANLIMIT=:[limit]{,:[limit]}" + -- https://modern.ircdocs.horse/#chanlimit-parameter + """ + self.connectClient("foo") + + if "CHANLIMIT" not in self.server_support: + raise runner.IsupportTokenNotSupported("CHANLIMIT") + + pairs = [ + part.split(":", 1) for part in self.server_support["CHANLIMIT"].split(",") + ] + limits = {pair[0]: int(pair[1]) if pair[1] else None for pair in pairs} + + self.assertTrue(limits, "CHANLIMIT is empty") + + limit = limits.get(prefix) + if limit is None: + raise runner.ImplementationChoice(f"No limit for {prefix} channels") + + # Join up to the limit + for i in range(limit): + self.sendLine(1, f"JOIN {prefix}chan{i}") + self.assertMessageMatch( + self.getMessage(1), + command="JOIN", + params=[f"{prefix}chan{i}"], + fail_msg=f"Failed to join channel {i + 1}/{limit}", + ) + self.getMessages(1) # clear any remaining messages + + # Try to join one more - should fail + self.sendLine(1, f"JOIN {prefix}chan") + self.assertMessageMatch( + self.getMessage(1), + command=ERR_TOOMANYCHANNELS, + params=["foo", f"{prefix}chan", ANYSTR], + ) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("CHANNELLEN") + @pytest.mark.parametrize("prefix", ["#", "&"]) + def testChannellen(self, prefix): + """ + "CHANNELLEN= + The CHANNELLEN parameter specifies the maximum length of a channel name that a client may join." + -- https://modern.ircdocs.horse/#channellen-parameter + """ + self.connectClient("foo") + + if "CHANNELLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("CHANNELLEN") + + chantypes = self.server_support.get("CHANTYPES", "#") + if prefix not in chantypes: + raise runner.NotImplementedByController( + f"Server does not support {prefix} channels" + ) + + channellen = int(self.server_support["CHANNELLEN"]) + + # Try a channel name at exactly the limit + valid_chan = prefix + "a" * (channellen - 1) + self.sendLine(1, f"JOIN {valid_chan}") + self.assertMessageMatch(self.getMessage(1), command="JOIN", params=[valid_chan]) + + # Try a channel name longer than the limit + self.getMessages(1) # clear + invalid_chan = prefix + "b" * channellen + self.sendLine(1, f"JOIN {invalid_chan}") + self.assertMessageMatch( + self.getMessage(1), + command=Either( + ERR_NOSUCHCHANNEL, + ERR_BADCHANNAME, + ERR_FORBIDDENCHANNEL, + ERR_BADCHANMASK, + ), + params=["foo", invalid_chan, ANYSTR], + ) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("NICKLEN") + def testNicklen(self): + """ + "NICKLEN= + "The NICKLEN parameter indicates the maximum length of a nickname that a client may set. + Clients on the network MAY have longer nicks than this. + The value MUST be specified and MUST be a positive integer. + 30 or 31 are typical values for this parameter advertised by servers today." + -- https://modern.ircdocs.horse/#nicklen-parameter + """ + self.connectClient("foo") + + if "NICKLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("NICKLEN") + + nicklen = int(self.server_support["NICKLEN"]) + + # Try a nick at exactly the limit + valid_nick = "a" * nicklen + self.sendLine(1, f"NICK {valid_nick}") + self.assertMessageMatch(self.getMessage(1), command="NICK", params=[valid_nick]) + + # Try a nick longer than the limit + invalid_nick = "b" * (nicklen + 5) + self.sendLine(1, f"NICK {invalid_nick}") + self.assertMessageMatch( + self.getMessage(1), + command=ERR_ERRONEUSNICKNAME, + params=[valid_nick, invalid_nick, ANYSTR], + ) diff --git a/irctest/server_tests/kick.py b/irctest/server_tests/kick.py index ac511dee..254d926c 100644 --- a/irctest/server_tests/kick.py +++ b/irctest/server_tests/kick.py @@ -272,3 +272,51 @@ def testDoubleKickMessages(self, multiple_targets): m1.params[1], m2.params[1] ) ) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("KICKLEN") + def testKicklen(self): + """ + "KICKLEN= + The KICKLEN parameter indicates the maximum length for the of a KICK command." + -- https://modern.ircdocs.horse/#kicklen-parameter + """ + self.connectClient("foo") + self.joinChannel(1, "#test") + + if "KICKLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("KICKLEN") + + self.connectClient("bar") + self.joinChannel(2, "#test") + self.getMessages(1) + + kicklen = int(self.server_support["KICKLEN"]) + + # Kick with a reason at exactly the limit + valid_reason = "a" * kicklen + self.sendLine(1, f"KICK #test bar :{valid_reason}") + msg = self.getMessage(1) + self.assertMessageMatch(msg, command="KICK", params=["#test", "bar", ANYSTR]) + self.assertLessEqual( + len(msg.params[2]), + kicklen, + f"Server sent kick reason longer than KICKLEN {kicklen}", + ) + + # Rejoin and kick with a reason longer than the limit + self.getMessages(1) + self.getMessages(2) + self.sendLine(2, "JOIN #test") + self.getMessages(2) + self.getMessages(1) + + long_reason = "b" * (kicklen + 50) + self.sendLine(1, f"KICK #test bar :{long_reason}") + msg = self.getMessage(1) + self.assertMessageMatch(msg, command="KICK", params=["#test", "bar", ANYSTR]) + self.assertLessEqual( + len(msg.params[2]), + kicklen, + f"Server did not truncate kick reason to KICKLEN {kicklen}", + ) diff --git a/irctest/server_tests/topic.py b/irctest/server_tests/topic.py index d001fe68..48816858 100644 --- a/irctest/server_tests/topic.py +++ b/irctest/server_tests/topic.py @@ -7,6 +7,7 @@ from irctest import cases, client_mock, runner from irctest.numerics import ERR_CHANOPRIVSNEEDED, RPL_NOTOPIC, RPL_TOPIC, RPL_TOPICTIME +from irctest.patma import ANYSTR class TopicTestCase(cases.BaseServerTestCase): @@ -172,6 +173,48 @@ def testUnsetTopicResponses(self): # topic is once again unset, shouldn't send RPL_NOTOPIC on initial join self.assertNotIn(RPL_NOTOPIC, [m.command for m in messages]) + @cases.mark_specifications("Modern") + @cases.mark_isupport("TOPICLEN") + def testTopiclen(self): + """ + "TOPICLEN= + The TOPICLEN parameter indicates the maximum length of a topic that a client may set on a channel." + -- https://modern.ircdocs.horse/#topiclen-parameter + """ + self.connectClient("foo") + self.joinChannel(1, "#test") + + if "TOPICLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("TOPICLEN") + + topiclen = int(self.server_support["TOPICLEN"]) + + # Set a topic at exactly the limit + valid_topic = "a" * topiclen + self.sendLine(1, f"TOPIC #test :{valid_topic}") + self.assertMessageMatch( + self.getMessage(1), command="TOPIC", params=["#test", valid_topic] + ) + + self.connectClient("bar") + self.sendLine(2, "JOIN #test") + (msg,) = [msg for msg in self.getMessages(2) if msg.command == RPL_TOPIC] + self.assertMessageMatch( + msg, command=RPL_TOPIC, params=["bar", "#test", valid_topic] + ) + + # Try a topic longer than the limit + self.getMessages(1) # clear + long_topic = "b" * (topiclen + 50) + self.sendLine(1, f"TOPIC #test :{long_topic}") + msg = self.getMessage(1) + self.assertMessageMatch(msg, command="TOPIC", params=["#test", ANYSTR]) + self.assertLessEqual( + len(msg.params[1]), + topiclen, + f"Server set topic longer than TOPICLEN {topiclen}", + ) + class TopicPrivilegesTestCase(cases.BaseServerTestCase): @cases.mark_specifications("RFC2812") @@ -214,7 +257,7 @@ def testTopicPrivileges(self): self.connectClient("buzz", name="buzz") self.sendLine("buzz", "JOIN #chan") replies = self.getMessages("buzz") - rpl_topic = [msg for msg in replies if msg.command == RPL_TOPIC][0] + (rpl_topic,) = [msg for msg in replies if msg.command == RPL_TOPIC] self.assertMessageMatch( rpl_topic, command=RPL_TOPIC, params=["buzz", "#chan", "new topic"] ) diff --git a/irctest/specifications.py b/irctest/specifications.py index e8533472..ae4e7d60 100644 --- a/irctest/specifications.py +++ b/irctest/specifications.py @@ -53,13 +53,19 @@ def from_name(cls, name: str) -> Capabilities: @enum.unique class IsupportTokens(enum.Enum): + AWAYLEN = "AWAYLEN" BOT = "BOT" + CHANLIMIT = "CHANLIMIT" + CHANNELLEN = "CHANNELLEN" ELIST = "ELIST" INVEX = "INVEX" - PREFIX = "PREFIX" + KICKLEN = "KICKLEN" MONITOR = "MONITOR" + NICKLEN = "NICKLEN" + PREFIX = "PREFIX" STATUSMSG = "STATUSMSG" TARGMAX = "TARGMAX" + TOPICLEN = "TOPICLEN" UTF8ONLY = "UTF8ONLY" WHOX = "WHOX" diff --git a/pytest.ini b/pytest.ini index 8f023dbd..51a63afb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -36,13 +36,19 @@ markers = sts # isupport tokens + AWAYLEN BOT + CHANLIMIT + CHANNELLEN ELIST INVEX + KICKLEN MONITOR + NICKLEN PREFIX STATUSMSG TARGMAX + TOPICLEN UTF8ONLY WHOX