From d185309f75d9d4a25a922f4f6d677d2cd7b2abc1 Mon Sep 17 00:00:00 2001 From: Val Lorentz Date: Sat, 10 Jan 2026 08:22:42 +0100 Subject: [PATCH 1/4] Add tests for mode +I --- .../server_tests/chmodes/invite_exception.py | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 irctest/server_tests/chmodes/invite_exception.py diff --git a/irctest/server_tests/chmodes/invite_exception.py b/irctest/server_tests/chmodes/invite_exception.py new file mode 100644 index 00000000..aa680ef0 --- /dev/null +++ b/irctest/server_tests/chmodes/invite_exception.py @@ -0,0 +1,261 @@ +""" +Invite exception mode (`Modern +`__) + +The invite exception mode allows channel operators to specify masks of users +who can join an invite-only channel without needing an explicit INVITE. +""" + +from irctest import cases, runner +from irctest.numerics import ERR_INVITEONLYCHAN, RPL_ENDOFINVITELIST, RPL_INVITELIST +from irctest.patma import ANYSTR, StrRe + + +@cases.mark_isupport("INVEX") +class InviteExceptionTestCase(cases.BaseServerTestCase): + def getInviteExceptionMode(self) -> str: + """Get the invite exception mode letter from ISUPPORT and validate it.""" + if "INVEX" in self.server_support: + mode = self.server_support["INVEX"] or "I" + if "CHANMODES" in self.server_support: + self.assertIn( + mode, + self.server_support["CHANMODES"], + fail_msg="ISUPPORT INVEX is present, but '{item}' is missing " + "from 'CHANMODES={list}'", + ) + self.assertIn( + mode, + self.server_support["CHANMODES"].split(",")[0], + fail_msg="ISUPPORT INVEX is present, but '{item}' is not " + "in group A", + ) + else: + mode = "I" + if "CHANMODES" in self.server_support: + if "I" not in self.server_support["CHANMODES"]: + raise runner.OptionalExtensionNotSupported( + "Invite exception (or mode letter is not +I)" + ) + self.assertIn( + mode, + self.server_support["CHANMODES"].split(",")[0], + fail_msg="Mode +I (assumed to be invite exception) is present, " + "but 'I' is not in group A", + ) + else: + raise runner.OptionalExtensionNotSupported("ISUPPORT CHANMODES") + return mode + + @cases.mark_specifications("Modern") + def testInviteException(self): + """Test that invite exception (+I) allows users to bypass invite-only (+i). + + https://modern.ircdocs.horse/#invite-exception-channel-mode + """ + self.connectClient("chanop", name="chanop") + mode = self.getInviteExceptionMode() + + # Create channel and set invite-only mode + self.joinChannel("chanop", "#chan") + self.getMessages("chanop") + + self.sendLine("chanop", "MODE #chan +i") + self.getMessages("chanop") + + # User matching no exception should be blocked + self.connectClient("Bar", name="bar") + self.sendLine("bar", "JOIN #chan") + self.assertMessageMatch(self.getMessage("bar"), command=ERR_INVITEONLYCHAN) + + # Set invite exception for bar!*@* + self.sendLine("chanop", f"MODE #chan +{mode} bar!*@*") + self.assertMessageMatch( + self.getMessage("chanop"), + command="MODE", + params=["#chan", f"+{mode}", "bar!*@*"], + ) + + # User matching the exception should now be able to join + self.sendLine("bar", "JOIN #chan") + self.assertMessageMatch(self.getMessage("bar"), command="JOIN") + + @cases.mark_specifications("Modern") + def testInviteExceptionList(self): + """Test querying the invite exception list. + + "346 RPL_INVEXLIST + " " + + Sent as a reply to the MODE command, when clients are viewing the current + entries on a channel’s invite-exception list. " + -- https://modern.ircdocs.horse/#rplinvexlist-346 + + "347 RPL_ENDOFINVEXLIST + " :End of Channel Invite Exception List" + + Sent as a reply to the MODE command, this numeric indicates the end of + a channel’s invite-exception list." + -- https://modern.ircdocs.horse/#rplendofinvexlist-347 + + Note: Some servers include optional [ ] parameters + like RPL_BANLIST does. + """ + self.connectClient("chanop", name="chanop") + mode = self.getInviteExceptionMode() + + self.joinChannel("chanop", "#chan") + self.getMessages("chanop") + + # Set an invite exception + self.sendLine("chanop", f"MODE #chan +{mode} bar!*@*") + self.assertMessageMatch( + self.getMessage("chanop"), + command="MODE", + params=["#chan", f"+{mode}", "bar!*@*"], + ) + + # Query the invite exception list + self.sendLine("chanop", f"MODE #chan +{mode}") + + m = self.getMessage("chanop") + if len(m.params) == 3: + # Old format + self.assertMessageMatch( + m, + command=RPL_INVITELIST, + params=[ + "chanop", + "#chan", + "bar!*@*", + ], + ) + else: + # Modern format with who set it and timestamp + self.assertMessageMatch( + m, + command=RPL_INVITELIST, + params=[ + "chanop", + "#chan", + "bar!*@*", + StrRe("chanop(!.*@.*)?"), + StrRe("[0-9]+"), + ], + ) + + self.assertMessageMatch( + self.getMessage("chanop"), + command=RPL_ENDOFINVITELIST, + params=[ + "chanop", + "#chan", + ANYSTR, + ], + ) + + @cases.mark_specifications("Modern") + def testInviteExceptionRemoval(self): + self.connectClient("chanop", name="chanop") + mode = self.getInviteExceptionMode() + + # Create channel and set invite-only mode with exception + self.joinChannel("chanop", "#chan") + self.getMessages("chanop") + + self.sendLine("chanop", "MODE #chan +i") + self.getMessages("chanop") + + self.sendLine("chanop", f"MODE #chan +{mode} bar!*@*") + self.assertMessageMatch( + self.getMessage("chanop"), + command="MODE", + params=["#chan", f"+{mode}", "bar!*@*"], + ) + + # User can join via exception + self.connectClient("Bar", name="bar") + self.sendLine("bar", "JOIN #chan") + self.assertMessageMatch(self.getMessage("bar"), command="JOIN") + + # User leaves + self.sendLine("bar", "PART #chan") + self.getMessages("bar") + self.getMessages("chanop") + + # Remove the exception + self.sendLine("chanop", f"MODE #chan -{mode} bar!*@*") + self.assertMessageMatch( + self.getMessage("chanop"), + command="MODE", + params=["#chan", f"-{mode}", "bar!*@*"], + ) + + # User should now be blocked + self.sendLine("bar", "JOIN #chan") + self.assertMessageMatch(self.getMessage("bar"), command=ERR_INVITEONLYCHAN) + + @cases.mark_specifications("Modern") + def testInviteExceptionWithoutInviteOnly(self): + self.connectClient("chanop", name="chanop") + mode = self.getInviteExceptionMode() + + # Create channel without invite-only mode + self.joinChannel("chanop", "#chan") + self.getMessages("chanop") + + # Set invite exception (should be allowed but has no effect) + self.sendLine("chanop", f"MODE #chan +{mode} bar!*@*") + self.assertMessageMatch( + self.getMessage("chanop"), + command="MODE", + params=["#chan", f"+{mode}", "bar!*@*"], + ) + + # User should be able to join regardless (channel is not +i) + self.connectClient("Baz", name="baz") + self.sendLine("baz", "JOIN #chan") + self.assertMessageMatch(self.getMessage("baz"), command="JOIN") + + @cases.mark_specifications("Modern") + def testInviteExceptionMultipleMasks(self): + self.connectClient("chanop", name="chanop") + mode = self.getInviteExceptionMode() + + # Create channel and set invite-only mode + self.joinChannel("chanop", "#chan") + self.getMessages("chanop") + + self.sendLine("chanop", "MODE #chan +i") + self.getMessages("chanop") + + # Set exception for bar!*@* but not baz!*@* + self.sendLine("chanop", f"MODE #chan +{mode} bar!*@*") + self.assertMessageMatch( + self.getMessage("chanop"), + command="MODE", + params=["#chan", f"+{mode}", "bar!*@*"], + ) + + # bar should be able to join + self.connectClient("Bar", name="bar") + self.sendLine("bar", "JOIN #chan") + self.assertMessageMatch(self.getMessage("bar"), command="JOIN") + self.getMessages("chanop") + + # baz should be blocked + self.connectClient("Baz", name="baz") + self.sendLine("baz", "JOIN #chan") + self.assertMessageMatch(self.getMessage("baz"), command=ERR_INVITEONLYCHAN) + + # Add exception for baz!*@* + self.sendLine("chanop", f"MODE #chan +{mode} baz!*@*") + self.assertMessageMatch( + self.getMessage("chanop"), + command="MODE", + params=["#chan", f"+{mode}", "baz!*@*"], + ) + + # baz should now be able to join + self.sendLine("baz", "JOIN #chan") + self.assertMessageMatch(self.getMessage("baz"), command="JOIN") From b399c428769d340fd3d4c51b2ea7dea92aad7af8 Mon Sep 17 00:00:00 2001 From: Val Lorentz Date: Sat, 10 Jan 2026 08:33:24 +0100 Subject: [PATCH 2/4] fix mypy --- .../server_tests/chmodes/invite_exception.py | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/irctest/server_tests/chmodes/invite_exception.py b/irctest/server_tests/chmodes/invite_exception.py index aa680ef0..7f809680 100644 --- a/irctest/server_tests/chmodes/invite_exception.py +++ b/irctest/server_tests/chmodes/invite_exception.py @@ -15,34 +15,38 @@ class InviteExceptionTestCase(cases.BaseServerTestCase): def getInviteExceptionMode(self) -> str: """Get the invite exception mode letter from ISUPPORT and validate it.""" - if "INVEX" in self.server_support: + if self.server_support and "INVEX" in self.server_support: mode = self.server_support["INVEX"] or "I" if "CHANMODES" in self.server_support: - self.assertIn( - mode, - self.server_support["CHANMODES"], - fail_msg="ISUPPORT INVEX is present, but '{item}' is missing " - "from 'CHANMODES={list}'", - ) - self.assertIn( - mode, - self.server_support["CHANMODES"].split(",")[0], - fail_msg="ISUPPORT INVEX is present, but '{item}' is not " - "in group A", - ) + chanmodes = self.server_support["CHANMODES"] + if chanmodes: + self.assertIn( + mode, + chanmodes, + fail_msg="ISUPPORT INVEX is present, but '{item}' is missing " + "from 'CHANMODES={list}'", + ) + self.assertIn( + mode, + chanmodes.split(",")[0], + fail_msg="ISUPPORT INVEX is present, but '{item}' is not " + "in group A", + ) else: mode = "I" - if "CHANMODES" in self.server_support: - if "I" not in self.server_support["CHANMODES"]: + if self.server_support and "CHANMODES" in self.server_support: + chanmodes = self.server_support["CHANMODES"] + if chanmodes and "I" not in chanmodes: raise runner.OptionalExtensionNotSupported( "Invite exception (or mode letter is not +I)" ) - self.assertIn( - mode, - self.server_support["CHANMODES"].split(",")[0], - fail_msg="Mode +I (assumed to be invite exception) is present, " - "but 'I' is not in group A", - ) + if chanmodes: + self.assertIn( + mode, + chanmodes.split(",")[0], + fail_msg="Mode +I (assumed to be invite exception) is present, " + "but 'I' is not in group A", + ) else: raise runner.OptionalExtensionNotSupported("ISUPPORT CHANMODES") return mode From f73f1d3978dda1a684ab0064e8a90aca7b5fb0ac Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 10 Jan 2026 08:38:08 +0100 Subject: [PATCH 3/4] Remove redundant test --- irctest/server_tests/invite.py | 44 ---------------------------------- 1 file changed, 44 deletions(-) diff --git a/irctest/server_tests/invite.py b/irctest/server_tests/invite.py index 5b6c43a6..d7e6a3e6 100644 --- a/irctest/server_tests/invite.py +++ b/irctest/server_tests/invite.py @@ -410,50 +410,6 @@ def testInviteList(self): params=["bar", ANYSTR], ) - @cases.mark_isupport("INVEX") - @cases.mark_specifications("Modern") - def testInvexList(self): - self.connectClient("foo") - self.getMessages(1) - - if "INVEX" in self.server_support: - invex = self.server_support.get("INVEX") or "I" - else: - raise runner.IsupportTokenNotSupported("INVEX") - - self.sendLine(1, "JOIN #chan") - self.getMessages(1) - - self.sendLine(1, f"MODE #chan +{invex} bar!*@*") - self.getMessages(1) - - self.sendLine(1, f"MODE #chan +{invex}") - m = self.getMessage(1) - if len(m.params) == 3: - # Old format - self.assertMessageMatch( - m, - command="346", - params=["foo", "#chan", "bar!*@*"], - ) - else: - self.assertMessageMatch( - m, - command="346", - params=[ - "foo", - "#chan", - "bar!*@*", - StrRe("foo(!.*@.*)?"), - StrRe("[0-9]+"), - ], - ) - self.assertMessageMatch( - self.getMessage(1), - command="347", - params=["foo", "#chan", ANYSTR], - ) - @cases.mark_specifications("Ergo") def testInviteExemptsFromBan(self): # regression test for ergochat/ergo#1876; From d73031c8699f1678c0812f191b9b2a3b0022f8a7 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 11 Jan 2026 09:27:10 +0100 Subject: [PATCH 4/4] RPL_INVITELIST/RPL_ENDOFINVITELIST -> RPL_INVEXLIST/RPL_ENDOFINVEXLIST --- irctest/numerics.py | 4 ++-- irctest/server_tests/chmodes/invite_exception.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/irctest/numerics.py b/irctest/numerics.py index 88c0fab9..48266559 100644 --- a/irctest/numerics.py +++ b/irctest/numerics.py @@ -80,8 +80,8 @@ RPL_WHOISACTUALLY = "338" RPL_INVITING = "341" RPL_SUMMONING = "342" -RPL_INVITELIST = "346" -RPL_ENDOFINVITELIST = "347" +RPL_INVEXLIST = "346" +RPL_ENDOFINVEXLIST = "347" RPL_EXCEPTLIST = "348" RPL_ENDOFEXCEPTLIST = "349" RPL_VERSION = "351" diff --git a/irctest/server_tests/chmodes/invite_exception.py b/irctest/server_tests/chmodes/invite_exception.py index 7f809680..820d6754 100644 --- a/irctest/server_tests/chmodes/invite_exception.py +++ b/irctest/server_tests/chmodes/invite_exception.py @@ -7,7 +7,7 @@ """ from irctest import cases, runner -from irctest.numerics import ERR_INVITEONLYCHAN, RPL_ENDOFINVITELIST, RPL_INVITELIST +from irctest.numerics import ERR_INVITEONLYCHAN, RPL_ENDOFINVEXLIST, RPL_INVEXLIST from irctest.patma import ANYSTR, StrRe @@ -127,7 +127,7 @@ def testInviteExceptionList(self): # Old format self.assertMessageMatch( m, - command=RPL_INVITELIST, + command=RPL_INVEXLIST, params=[ "chanop", "#chan", @@ -138,7 +138,7 @@ def testInviteExceptionList(self): # Modern format with who set it and timestamp self.assertMessageMatch( m, - command=RPL_INVITELIST, + command=RPL_INVEXLIST, params=[ "chanop", "#chan", @@ -150,7 +150,7 @@ def testInviteExceptionList(self): self.assertMessageMatch( self.getMessage("chanop"), - command=RPL_ENDOFINVITELIST, + command=RPL_ENDOFINVEXLIST, params=[ "chanop", "#chan",